├── public ├── logo.png ├── preview.jpg ├── 404.html └── checkDb.js ├── src ├── assets │ ├── background.png │ ├── main.css │ └── base.css ├── api_client │ └── servicesApi.js ├── filters │ ├── localDateFilter.js │ └── servicesFilter.js ├── stores │ ├── servicesCounterStore.js │ ├── searchStore.js │ ├── ranksStore.js │ ├── currentLangStore.js │ ├── tagsStore.js │ ├── servicesStore.js │ ├── orderStore.js │ └── rootFilterStore.js ├── views │ ├── ServiceView.vue │ └── HomeView.vue ├── App.vue ├── main.js ├── components │ ├── settings │ │ ├── components │ │ │ ├── DarkModeSwitcher.vue │ │ │ ├── LocaleSwitcher.vue │ │ │ └── ServicesOrderSelector.vue │ │ └── Settings.vue │ ├── TagList.vue │ ├── ServiceRating.vue │ ├── Loader.vue │ ├── Footer.vue │ ├── search │ │ ├── Search.vue │ │ ├── FilterComponent.vue │ │ └── AutoComplete.vue │ ├── ServicesList.vue │ ├── StarFilter.vue │ ├── service_card │ │ ├── components │ │ │ └── Sharing.vue │ │ ├── ServiceItem.vue │ │ └── ServiceListItem.vue │ └── Header.vue ├── utils │ └── utils.js ├── router │ └── index.js ├── directives │ └── clickOutsideDirective.js └── i18n │ └── index.js ├── jsconfig.json ├── .gitignore ├── vite.config.js ├── .github ├── ISSUE_TEMPLATE │ ├── сlosed-or-inoperative-tool-report.md │ └── feature_request.md └── workflows │ └── static.yml ├── .eslintrc.cjs ├── package.json ├── index.html └── README.md /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awclub/catalog/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awclub/catalog/HEAD/public/preview.jpg -------------------------------------------------------------------------------- /src/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awclub/catalog/HEAD/src/assets/background.png -------------------------------------------------------------------------------- /src/api_client/servicesApi.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export function GetServices (url) { 4 | return axios.get(url) 5 | } -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /src/filters/localDateFilter.js: -------------------------------------------------------------------------------- 1 | export function localDateFilter(inputDate, currentLanguage) { 2 | let date = new Date(inputDate); 3 | let options = { year: 'numeric', month: 'long', day: 'numeric' }; 4 | 5 | return date.toLocaleDateString(currentLanguage, options); 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | .vite 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /src/stores/servicesCounterStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useServicesCounterStore = defineStore('servicesCounterStore', { 4 | state: () => ({ 5 | count: 0 6 | }), 7 | getters: { 8 | getCount(state) { 9 | return state.count; 10 | } 11 | }, 12 | actions: { 13 | setCount(value) { 14 | this.count = value 15 | } 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { URL, fileURLToPath } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | }, 16 | base: '/catalog/', 17 | }) 18 | -------------------------------------------------------------------------------- /src/views/ServiceView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/сlosed-or-inoperative-tool-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Сlosed or inoperative tool report 3 | about: Create a report to help us improve our catalog / Сообщить о закрытом или неработающем 4 | сервисе из каталога 5 | title: '' 6 | labels: closed tool, inoperative tool 7 | assignees: '' 8 | 9 | --- 10 | 11 | **Name and URL of the tool** 12 | **Description** 13 | A clear and concise description of what the bug is. 14 | -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | 3 | a { 4 | color: var(--link-color); 5 | text-decoration: none; 6 | transition: color 0.3s; 7 | } 8 | 9 | a:hover { 10 | text-decoration: underline; 11 | } 12 | 13 | .text-input { 14 | font-size: 1em; 15 | box-sizing: border-box; 16 | padding: 5px 10px; 17 | background-color: rgb(241, 235, 244); 18 | border: 1px solid #ddd; 19 | border-radius: 8px; 20 | } 21 | 22 | .text-input:focus { 23 | outline: none; 24 | } 25 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | 3 | import { createApp } from 'vue' 4 | import { createPinia } from 'pinia' 5 | 6 | import App from './App.vue' 7 | import { clickOutsideDirective } from "./directives/clickOutsideDirective.js"; 8 | import i18n from './i18n/index.js' 9 | import router from './router/index.js' 10 | 11 | const app = createApp(App) 12 | 13 | app.use(createPinia()) 14 | app.use(i18n) 15 | app.use(router) 16 | app.directive('clickOutside', clickOutsideDirective); 17 | 18 | app.mount('#app') 19 | -------------------------------------------------------------------------------- /src/components/settings/components/DarkModeSwitcher.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | 23 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:vue/vue3-recommended', 4 | "stylelint", 5 | ], 6 | rules: { 7 | 'vue/multi-word-component-names': 'off', 8 | 'vue/require-default-prop': 'off', 9 | "indent": ["error", "tab"], 10 | "vue/html-indent": ["error", "tab"], 11 | "vue/script-indent": ["error", "tab"], 12 | "sort-imports": "off", 13 | }, 14 | env: { 15 | "browser": true, 16 | "node": true 17 | }, 18 | parserOptions: { 19 | "ecmaVersion": 15 20 | }, 21 | ignorePatterns: [ 22 | 'checkDb.js' 23 | ] 24 | } -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | export const isOrDefault = (value, predicate, defaultValue, clb) => { 2 | if (predicate(value)) return value; 3 | 4 | clb && clb(); 5 | 6 | return defaultValue; 7 | } 8 | 9 | export const oneOf = (...args) => value => args.includes(value); 10 | 11 | export const not = predicate => value => !predicate(value); 12 | 13 | export const numberOrNull = value => { 14 | return isNaN(value) ? null : value; 15 | } 16 | 17 | export const parseJson = json => { 18 | try { 19 | return JSON.parse(json); 20 | } catch (_) { 21 | return json; 22 | } 23 | } -------------------------------------------------------------------------------- /src/components/settings/components/LocaleSwitcher.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 24 | 25 | -------------------------------------------------------------------------------- /src/stores/searchStore.js: -------------------------------------------------------------------------------- 1 | import { useRootFilterStore } from "./rootFilterStore.js"; 2 | import { defineStore } from "pinia"; 3 | import { computed, ref } from "vue"; 4 | 5 | export const useSearchStore = defineStore('searchStore', () => { 6 | const rootFilterStore = useRootFilterStore(); 7 | 8 | // state 9 | const searchText = ref(rootFilterStore.text); 10 | 11 | // getters 12 | const getSearchText = computed(() => searchText.value); 13 | 14 | //actions 15 | function setSearchText(text) { 16 | searchText.value = text; 17 | rootFilterStore.setSearchText(text); 18 | } 19 | 20 | return { 21 | getSearchText, 22 | setSearchText 23 | }; 24 | }); -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import HomeView from '../views/HomeView.vue' 3 | import ServiceView from '../views/ServiceView.vue' 4 | 5 | const router = createRouter({ 6 | history: createWebHistory(import.meta.env.BASE_URL), 7 | routes: [ 8 | { 9 | path: '/', 10 | name: 'home', 11 | component: HomeView, 12 | beforeEnter: (to, from, next) => { 13 | if (to.query.id) { 14 | next({ name: 'item', params: { id: to.query.id } }); 15 | } else { 16 | next(); 17 | } 18 | } 19 | }, 20 | { 21 | path: '/:id', 22 | name: 'item', 23 | component: ServiceView, 24 | props: true 25 | } 26 | ] 27 | }) 28 | 29 | export default router 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this catalog / Идея для каталога 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/directives/clickOutsideDirective.js: -------------------------------------------------------------------------------- 1 | export const clickOutsideDirective = { 2 | mounted: (el, binding) => { 3 | // Define a function to handle clicks 4 | el.__clickOutsideHandler__ = (event) => { 5 | // Check if the click was outside the el and its children 6 | if (!(el === event.target || el.contains(event.target))) { 7 | // Call the method provided as the directive's value 8 | binding.value(event); 9 | } 10 | }; 11 | // Attach the click handler to the document 12 | document.addEventListener('click', el.__clickOutsideHandler__); 13 | }, 14 | unmounted: (el) => { 15 | // Remove the click handler from the document 16 | document.removeEventListener('click', el.__clickOutsideHandler__); 17 | el.__clickOutsideHandler__ = null; 18 | } 19 | } -------------------------------------------------------------------------------- /src/stores/ranksStore.js: -------------------------------------------------------------------------------- 1 | import { useRootFilterStore } from "./rootFilterStore.js"; 2 | import { defineStore } from "pinia"; 3 | import { computed, ref } from "vue"; 4 | 5 | export const useRanksStore = defineStore('ranksStore', () => { 6 | const rootFilterStore = useRootFilterStore(); 7 | 8 | // state 9 | const selectedRank = ref(rootFilterStore.rank); 10 | 11 | // getters 12 | const getSelectedRanks = computed(() => selectedRank.value); 13 | 14 | // actions 15 | function setRank(newRank) { 16 | selectedRank.value = newRank; 17 | rootFilterStore.setRank(newRank); 18 | } 19 | 20 | function resetRank() { 21 | selectedRank.value = 0; 22 | rootFilterStore.setRank(0); 23 | } 24 | 25 | return { 26 | getSelectedRanks, 27 | setRank, 28 | resetRank 29 | }; 30 | }); -------------------------------------------------------------------------------- /src/components/TagList.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/ServiceRating.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | 17 | -------------------------------------------------------------------------------- /src/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 30 | 31 | ../stores/searchStore.js -------------------------------------------------------------------------------- /src/stores/currentLangStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import i18n from "../i18n/index.js"; 3 | import { ref } from "vue"; 4 | import { useRootFilterStore } from "./rootFilterStore.js"; 5 | 6 | export const ALL_LANGS = ['ru', 'uk', 'be', 'en']; 7 | 8 | const defaultLang = (savedLang) => { 9 | // default language is English, 10 | const defaultLangTemp = savedLang 11 | || (['ru', 'uk', 'be'].some(lang => navigator.language.startsWith(lang)) ? 'ru' : 'en'); 12 | 13 | i18n.global.locale = defaultLangTemp; 14 | 15 | return defaultLangTemp; 16 | }; 17 | 18 | export const useCurrentLangStore = defineStore('currentLangStore', () => { 19 | const rootFilterStore = useRootFilterStore(); 20 | 21 | // state 22 | const currentLang = ref(defaultLang(rootFilterStore.lang)); 23 | 24 | // actions 25 | function setCurrentLang(lang) { 26 | currentLang.value = lang; 27 | i18n.global.locale = lang; 28 | rootFilterStore.setLang(lang); 29 | } 30 | 31 | return { 32 | setCurrentLang, 33 | } 34 | }); -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | name: Build To Deployment Branch 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | build-and-deploy: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: '18' 29 | 30 | - name: Install dependencies 31 | run: npm install 32 | 33 | - name: Lint 34 | run: npm run lint 35 | 36 | - name: Build 37 | run: npm run build 38 | 39 | - name: Check db.json 40 | run: npm run check-db 41 | 42 | - name: Deploy to GitHub Pages Repo 43 | uses: JamesIves/github-pages-deploy-action@4.1.5 44 | with: 45 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 46 | branch: gh-pages 47 | folder: dist -------------------------------------------------------------------------------- /src/stores/tagsStore.js: -------------------------------------------------------------------------------- 1 | import { computed, ref } from "vue"; 2 | import { defineStore } from "pinia"; 3 | import { useRootFilterStore } from "./rootFilterStore.js"; 4 | 5 | export const useTagsStore = defineStore('tagsStore', () => { 6 | const rootFilterStore = useRootFilterStore(); 7 | 8 | // state 9 | const selectedTags = ref(rootFilterStore.tags); 10 | 11 | // getters 12 | const getSelectedTags = computed(() => selectedTags.value); 13 | 14 | // actions 15 | function selectTag(tagName) { 16 | if (!selectedTags.value.includes(tagName)) { 17 | selectedTags.value = [...selectedTags.value, tagName]; 18 | rootFilterStore.setTags(selectedTags.value); 19 | } 20 | } 21 | 22 | function unSelectTag(tagName) { 23 | selectedTags.value = selectedTags.value.filter(tag => tag !== tagName); 24 | rootFilterStore.setTags(selectedTags.value); 25 | } 26 | 27 | function resetTags() { 28 | selectedTags.value = []; 29 | rootFilterStore.setTags(selectedTags.value); 30 | } 31 | 32 | return { 33 | getSelectedTags, 34 | selectTag, 35 | unSelectTag, 36 | resetTags 37 | }; 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aiacatalog", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm run lint && vite", 8 | "build": "npm run lint && vite build", 9 | "preview": "npm run lint && vite preview", 10 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", 11 | "format": "prettier --write src/", 12 | "check-db": "node ./public/checkDb.js" 13 | }, 14 | "dependencies": { 15 | "@vueuse/core": "^10.7.1", 16 | "axios": "^1.6.5", 17 | "pinia": "^2.1.7", 18 | "vue": "^3.3.11", 19 | "vue-i18n": "^9.9.0", 20 | "vue-router": "^4.2.5", 21 | "vue3-popper": "^1.5.0" 22 | }, 23 | "devDependencies": { 24 | "@babel/eslint-parser": "^7.5.4", 25 | "@babel/preset-react": "^7.23.3", 26 | "@rushstack/eslint-patch": "^1.3.3", 27 | "@vitejs/plugin-vue": "^4.5.2", 28 | "@vue/eslint-config-prettier": "^8.0.0", 29 | "eslint": "^8.56.0", 30 | "eslint-config-stylelint": "^20.0.0", 31 | "eslint-plugin-vue": "^9.20.1", 32 | "prettier": "^3.0.3", 33 | "vite": "^5.0.10" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/checkDb.js: -------------------------------------------------------------------------------- 1 | import data from './db.json' assert { type: 'json' }; 2 | 3 | /** 4 | * @param services {{id: string, name: string}[]} List of services to check the uniqueness by id field 5 | * @returns {{ duplicates: [{ id: string, names: [string]} ]}} 6 | */ 7 | function checkUniqueness(services) { 8 | const map = {}; 9 | 10 | for (const service of services) { 11 | map[service.id] = [ ...(map[service.id] || []), service.name ]; 12 | } 13 | 14 | const duplicates = []; 15 | 16 | for (const key of Object.keys(map)) { 17 | if (map[key].length > 1) { 18 | duplicates.push({ 19 | id: key, 20 | names: map[key].map(name => `"${name}"`), 21 | }); 22 | } 23 | } 24 | 25 | return ({ duplicates }); 26 | } 27 | 28 | /** 29 | * @param result {{ duplicates: [{ id: string, names: [string]} ]}} 30 | */ 31 | function throwDuplicationError(result) { 32 | const message = result.duplicates 33 | .map((current) => `id: ${current.id} \nnames: ${current.names.join(', ')}`) 34 | .join('\n\n'); 35 | 36 | throw new Error(`Found duplicates in db.json: \n${message}`); 37 | } 38 | 39 | /** 40 | * Main function to check db data. 41 | */ 42 | function checkDb(){ 43 | const result = checkUniqueness(data); 44 | 45 | if (result.duplicates.length) { 46 | throwDuplicationError(result); 47 | } 48 | } 49 | 50 | checkDb(); -------------------------------------------------------------------------------- /src/components/search/Search.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | 30 | ../stores/searchStore.js -------------------------------------------------------------------------------- /src/i18n/index.js: -------------------------------------------------------------------------------- 1 | import { KEYWORDS } from "../stores/rootFilterStore.js"; 2 | import { createI18n } from 'vue-i18n'; 3 | 4 | const i18n = createI18n({ 5 | // default locale 6 | locale: localStorage.getItem(KEYWORDS.LANG), 7 | globalInjection: true, 8 | legacy: true, 9 | // translations 10 | messages: { 11 | en: { 12 | title: "AI tools catalog by AIA Podcast & Anywher Club", 13 | header: "AI tools catalog by", 14 | searchPlaceholder: "Search by name & description...", 15 | mentionedIn: "Mentioned in", 16 | githubCodeLinkText: "Code on GitHub", 17 | podcastChatLinkText: "Podcast chat", 18 | podcastLinkText: "AIA Podcast", 19 | searchTagsPlaceholder: "Search by tags...", 20 | shareResult: "Copied to clipboard", 21 | sortingLegend: "Sorting", 22 | sortingByName: "Name", 23 | sortingByDate: "Date", 24 | themeDark: "Dark Theme", 25 | themeLight: "Light Theme", 26 | }, 27 | ru: { 28 | title: "Каталог ИИ-сервисов от AIA Podcast & Anywher Club", 29 | header: "Каталог ИИ-сервисов от", 30 | searchPlaceholder: "Поиск по названию и описанию...", 31 | mentionedIn: "Упомянут в", 32 | githubCodeLinkText: "Код на GitHub", 33 | podcastChatLinkText: "Чат подкаста", 34 | podcastLinkText: "AIA Podcast", 35 | searchTagsPlaceholder: "Поиск по тегам...", 36 | shareResult: "Ссылка скопирована", 37 | sortingLegend: "Сортировка", 38 | sortingByName: "Имя", 39 | sortingByDate: "Дата", 40 | themeDark: "Тёмная тема", 41 | themeLight: "Светлая тема", 42 | }, 43 | }, 44 | }); 45 | 46 | 47 | export default i18n -------------------------------------------------------------------------------- /src/components/settings/components/ServicesOrderSelector.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | 30 | ../stores/orderStore.js -------------------------------------------------------------------------------- /src/stores/servicesStore.js: -------------------------------------------------------------------------------- 1 | import { GetServices } from "../api_client/servicesApi.js"; 2 | import { defineStore } from 'pinia'; 3 | 4 | export const useServicesStore = defineStore('servicesStore', { 5 | state: () => ({ 6 | services: [], 7 | service: {}, 8 | tags: [], 9 | selectedRank: 0, 10 | }), 11 | getters: { 12 | getServices(state) { 13 | return state.services 14 | }, 15 | getService(state) { 16 | return state.service 17 | }, 18 | getTags(state) { 19 | return state.tags 20 | }, 21 | getSelectedRank(state) { 22 | return state.selectedRank 23 | } 24 | }, 25 | actions: { 26 | async fetchServices() { 27 | const api = await new GetServices(`./db.json?${new Date().getTime()}`) 28 | 29 | const response = api 30 | const { data } = response 31 | 32 | this.services = data 33 | }, 34 | async fetchService(id) { 35 | const api = await new GetServices(`./db.json?${new Date().getTime()}`) 36 | 37 | const currentId = id 38 | const response = api 39 | const { data } = response 40 | 41 | const service = data.find(i => i.id.includes(currentId)) 42 | 43 | this.service = service 44 | }, 45 | async fetchTags() { 46 | const { data } = await new GetServices(`./db.json?${new Date().getTime()}`); 47 | 48 | const uniqueAvailableTags = Object.keys( 49 | (data || []) 50 | .flatMap(service => service.tags) 51 | .reduce((prev, curr) => { 52 | prev[curr] = true; 53 | 54 | return prev; 55 | }, {}) 56 | ); 57 | 58 | uniqueAvailableTags.sort(); 59 | this.tags = uniqueAvailableTags; 60 | } 61 | }, 62 | }); -------------------------------------------------------------------------------- /src/components/ServicesList.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/StarFilter.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 50 | 51 | 89 | ../stores/ranksStore.js -------------------------------------------------------------------------------- /src/filters/servicesFilter.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import i18n from "../i18n/index.js"; 3 | import { useServicesCounterStore } from "../stores/servicesCounterStore.js"; 4 | import { useSearchStore } from "../stores/searchStore.js"; 5 | import { useTagsStore } from "../stores/tagsStore.js"; 6 | import { useRanksStore } from "../stores/ranksStore.js"; 7 | 8 | export const useServicesFilter = defineStore('servicesFilter', () => { 9 | const tagsStore = useTagsStore(); 10 | const searchStore = useSearchStore(); 11 | const servicesCounterStore = useServicesCounterStore(); 12 | const ranksStore = useRanksStore(); 13 | 14 | const _containsAllTags = (serviceTags, selectedTags = []) => { 15 | serviceTags = serviceTags.map(tag => tag.toLowerCase()); 16 | selectedTags = selectedTags.map(tag => tag.toLowerCase()); 17 | 18 | return selectedTags.every(tag => serviceTags.includes(tag)); 19 | } 20 | 21 | const _isSuitableServiceBySearchTerm = (service, searchTerm, currentLanguage) => { 22 | searchTerm = searchTerm.toLowerCase(); 23 | 24 | return service.name.toLowerCase().includes(searchTerm) || 25 | service.description[currentLanguage].toLowerCase().includes(searchTerm) || 26 | service.mentions.some(mention => mention.episodeUrl.toLowerCase().startsWith(searchTerm)) || 27 | service.mentions.some(mention => mention.episodeName.toLowerCase().includes(searchTerm)) || 28 | service.url.toLowerCase().includes(searchTerm) || 29 | service.id.toLowerCase().includes(searchTerm); 30 | } 31 | 32 | const applyFilter = (services = []) => { 33 | const selectedTags = tagsStore.getSelectedTags; 34 | const filtered = services 35 | .filter(service => _containsAllTags(service.tags, selectedTags)) 36 | .filter(service => _isSuitableServiceBySearchTerm(service, searchStore.getSearchText, i18n.global.locale)) 37 | .filter(service => ranksStore.getSelectedRanks === 0 || service.rank === ranksStore.getSelectedRanks); 38 | 39 | servicesCounterStore.setCount(filtered.length); 40 | 41 | return filtered; 42 | }; 43 | 44 | return { 45 | applyFilter 46 | }; 47 | }); -------------------------------------------------------------------------------- /src/components/service_card/components/Sharing.vue: -------------------------------------------------------------------------------- 1 | 2 | 31 | 32 | 68 | 69 | -------------------------------------------------------------------------------- /src/components/settings/Settings.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | AI tools catalog by AIA Podcast 21 | 22 | 23 | 26 | 29 | 37 | 38 | 39 | 40 | 47 | 48 | 49 | 58 | 59 | 60 | 61 |
62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/assets/base.css: -------------------------------------------------------------------------------- 1 | /* color palette from */ 2 | :root { 3 | --background-color: #fff; 4 | --text-color: #333; 5 | --link-color: #1a73e8; 6 | --header-bg-color: #f1ebf4; 7 | --service-item-bg-color: rgba(241, 235, 244, 0.8); 8 | --service-item-border-color: #ddd; 9 | --button-bg-color: #e2e2e2; 10 | --button-text-color: #333; 11 | --tag-bg-color: #9c94d3; 12 | --tag-text-color: #fff; 13 | --aw-club-color: #7e34bb; 14 | --autocomplete-item-text-color: #e1e1e1; 15 | --autocomplete-item-bg-color: #9c94d3; 16 | --autocomplete-item-text-color-hover: #fff; 17 | --autocomplete-item-bg-color-hover: #524980; 18 | --autocomplete-item-text-color-active: #e1e1e1; 19 | --autocomplete-item-bg-color-active: #423465; 20 | --reset-btn-bg-color: rgba(102, 50, 136, 0.7); 21 | --reset-btn-bg-color-hover: rgba(102, 50, 136, 0.9); 22 | --reset-btn-text-color: #f3e7e7; 23 | --copy-to-clipboard-btn-color: #a7a7a7; 24 | --copy-to-clipboard-btn-color-hover: #404040; 25 | --popper-theme-background-color: #333333; 26 | --popper-theme-background-color-hover: #333333; 27 | --popper-theme-text-color: #ffffff; 28 | --popper-theme-border-width: 0px; 29 | --popper-theme-border-style: solid; 30 | --popper-theme-border-radius: 6px; 31 | --popper-theme-padding: 8px 0px; 32 | --popper-theme-box-shadow: 0 6px 30px -6px rgba(0, 0, 0, 0.25); 33 | } 34 | 35 | :root.dark { 36 | --background-color: #1a1b1e; 37 | --text-color: #f5f5f5; 38 | --link-color: #4a90e2; 39 | --header-bg-color: #26272b; 40 | --service-item-bg-color: rgba(47, 48, 53, 0.8); 41 | --service-item-border-color: #ddd2; 42 | --button-bg-color: #3b3c40; 43 | --button-text-color: #f5f5f5; 44 | --tag-bg-color: #7b769e; 45 | --tag-text-color: #e1e1e1; 46 | --aw-club-color: #a449ee; 47 | --autocomplete-item-text-color: #e1e1e1; 48 | --autocomplete-item-bg-color: #6d609d; 49 | --autocomplete-item-text-color-hover: #fff; 50 | --autocomplete-item-bg-color-hover: #524980; 51 | --autocomplete-item-text-color-active: #e1e1e1; 52 | --autocomplete-item-bg-color-active: #423465; 53 | --reset-btn-bg-color: rgba(158, 98, 208, 0.7); 54 | --reset-btn-bg-color-hover: rgba(158, 98, 208, 0.9); 55 | --reset-btn-text-color: #d2d2d2; 56 | --copy-to-clipboard-btn-color: #6b6a6a; 57 | --copy-to-clipboard-btn-color-hover: #d2d2d2; 58 | } 59 | 60 | *, 61 | *::before, 62 | *::after { 63 | box-sizing: border-box; 64 | margin: 0; 65 | } 66 | 67 | strong { 68 | font-weight: bolder; 69 | } 70 | 71 | body { 72 | min-height: 100vh; 73 | color: var(--text-color); 74 | background: var(--background-color); 75 | transition: 76 | color 0.5s, 77 | background-color 0.5s; 78 | font-family: 'Roboto', Arial, sans-serif; 79 | font-weight: normal; 80 | text-rendering: optimizeLegibility; 81 | -webkit-font-smoothing: antialiased; 82 | -moz-osx-font-smoothing: grayscale; 83 | margin: 0; 84 | background-image: url('./background.png'); 85 | background-size: cover; 86 | background-attachment: fixed; 87 | background-position: center; 88 | transition: background-color 0.3s, color 0.3s; 89 | } 90 | -------------------------------------------------------------------------------- /src/stores/orderStore.js: -------------------------------------------------------------------------------- 1 | import { computed, ref } from "vue"; 2 | import { defineStore } from "pinia"; 3 | import { useRootFilterStore } from "./rootFilterStore.js"; 4 | 5 | export const DIRECTION = { 6 | ASC: 'ASC', 7 | DESC: 'DESC' 8 | }; 9 | 10 | // Comparator should sort the items by ASC always. DESC will be applied in runtime. 11 | const ORDERS = [ 12 | { 13 | key: 'name', 14 | textLabelKey: 'sortingByName', 15 | comparator: (first, second) => first.name.localeCompare(second.name) 16 | }, 17 | { 18 | key: 'date', 19 | textLabelKey: 'sortingByDate', 20 | comparator: (first, second) => first.date.localeCompare(second.date) 21 | } 22 | ]; 23 | 24 | export const ALL_ORDERS = ORDERS.flatMap( 25 | order => Object.values(DIRECTION) 26 | .map((direction) => (`${direction}-${order.key}`)) 27 | ); 28 | 29 | const DEFAULT_ORDER = [ 'date', DIRECTION.DESC ]; 30 | 31 | const _parseSavedState = (state) => { 32 | if (!state) { 33 | return DEFAULT_ORDER; 34 | } 35 | 36 | const parts = state.split('-'); 37 | 38 | if (parts.length !== 2) { 39 | return DEFAULT_ORDER; 40 | } 41 | 42 | const direction = (DIRECTION.DESC.toLowerCase() === parts[1].toLowerCase()) ? DIRECTION.DESC : DIRECTION.ASC; 43 | 44 | return [ parts[0], direction ]; 45 | }; 46 | 47 | const _buildInitialViewSettings = (savedState) => { 48 | const defaultSettings = ORDERS.reduce((obj, order) => ({ 49 | ...obj, 50 | [order.key]: DIRECTION.ASC 51 | }), {}); 52 | const [ field, direction ] = _parseSavedState(savedState); 53 | 54 | defaultSettings[field] = direction; 55 | 56 | return defaultSettings; 57 | }; 58 | 59 | const _reverted = (direction) => { 60 | return direction === DIRECTION.ASC ? DIRECTION.DESC : DIRECTION.ASC; 61 | }; 62 | 63 | export const useOrderStore = defineStore('orderStore', () => { 64 | const rootFilterStore = useRootFilterStore(); 65 | 66 | // state 67 | const orderViewSettings = ref(_buildInitialViewSettings(rootFilterStore.order) || {}); 68 | const selectedOrder = ref(_parseSavedState(rootFilterStore.order)[0]); 69 | 70 | // getters 71 | const getOrderViewSettings = computed(() => orderViewSettings.value); 72 | const getSelectedOrder = computed(() => selectedOrder.value); 73 | const getSelectedComparator = computed(() => { 74 | const comparator = ORDERS.find(order => order.key === selectedOrder.value).comparator; 75 | 76 | if (orderViewSettings.value[selectedOrder.value] === DIRECTION.ASC) { 77 | return comparator; 78 | } 79 | 80 | return (first, second) => -comparator(first, second); 81 | }); 82 | 83 | // actions 84 | const toggleOrder = (orderKey) => { 85 | if (selectedOrder.value === orderKey) { 86 | // just revert direction 87 | orderViewSettings.value = { 88 | ...orderViewSettings.value, 89 | [orderKey]: _reverted(orderViewSettings.value[orderKey]) 90 | }; 91 | } else { 92 | selectedOrder.value = orderKey; 93 | } 94 | 95 | rootFilterStore.setOrder(`${selectedOrder.value}-${orderViewSettings.value[selectedOrder.value]}`); 96 | }; 97 | 98 | return { 99 | getOrderViewSettings, 100 | getOrders: ORDERS, 101 | getSelectedComparator, 102 | getSelectedOrder, 103 | toggleOrder 104 | }; 105 | }) -------------------------------------------------------------------------------- /src/components/service_card/ServiceItem.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 73 | 74 | -------------------------------------------------------------------------------- /src/components/search/FilterComponent.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 76 | 77 | 122 | 123 | -------------------------------------------------------------------------------- /src/components/search/AutoComplete.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 100 | 101 | -------------------------------------------------------------------------------- /src/stores/rootFilterStore.js: -------------------------------------------------------------------------------- 1 | import { isOrDefault, not, numberOrNull, oneOf, parseJson } from "../utils/utils.js"; 2 | import { ALL_LANGS } from "../stores/currentLangStore.js"; 3 | import { ALL_ORDERS } from "../stores/orderStore.js"; 4 | import { defineStore } from "pinia"; 5 | 6 | export const KEYWORDS = { 7 | LANG: 'currentLanguage', 8 | ORDER: 'sortingOrder', 9 | TAGS: 'selectedTags', 10 | RANK: 'selectedRanks', 11 | TEXT: 'search' 12 | }; 13 | 14 | const EXPORTED_KEYWORDS = [ 15 | KEYWORDS.RANK, 16 | KEYWORDS.TAGS, 17 | KEYWORDS.TEXT 18 | ]; 19 | 20 | const _initState = () => { 21 | return { 22 | [ KEYWORDS.LANG ]: isOrDefault( 23 | localStorage.getItem( KEYWORDS.LANG ), 24 | oneOf(ALL_LANGS), 25 | 'en', 26 | () => localStorage.setItem(KEYWORDS.LANG, 'en') 27 | ), 28 | [ KEYWORDS.ORDER ]: isOrDefault( 29 | localStorage.getItem( KEYWORDS.ORDER ), 30 | oneOf(ALL_ORDERS), 31 | 'date-DESC', 32 | () => localStorage.setItem(KEYWORDS.ORDER, 'date-DESC') 33 | ), 34 | [ KEYWORDS.TAGS ]: isOrDefault( 35 | parseJson(localStorage.getItem( KEYWORDS.TAGS )), 36 | Array.isArray, 37 | [], 38 | () => localStorage.setItem(KEYWORDS.TAGS, '[]') 39 | ), 40 | [ KEYWORDS.RANK ]: isOrDefault( 41 | parseInt(numberOrNull(localStorage.getItem( KEYWORDS.RANK ))), 42 | not(isNaN), 43 | 0, 44 | () => localStorage.setItem(KEYWORDS.RANK, '0') 45 | ), 46 | [ KEYWORDS.TEXT ]: localStorage.getItem( KEYWORDS.TEXT ) || '', 47 | }; 48 | }; 49 | 50 | const _saveChanges = (key, value) => localStorage.setItem(key, value); 51 | 52 | export const useRootFilterStore = defineStore('rootFilterStore', ({ 53 | state: () => _initState(), 54 | getters: { 55 | lang: state => state[KEYWORDS.LANG], 56 | order: state => state[KEYWORDS.ORDER], 57 | tags: state => state[KEYWORDS.TAGS], 58 | rank: state => state[KEYWORDS.RANK], 59 | text: state => state[KEYWORDS.TEXT], 60 | searchState: state => { 61 | return EXPORTED_KEYWORDS.reduce((prev, key) => ({ ...prev, [key]: state[key] }), {}); 62 | }, 63 | }, 64 | actions: { 65 | importFilterState(query) { 66 | this[KEYWORDS.RANK] = isOrDefault( 67 | numberOrNull(parseInt(query[KEYWORDS.RANK] || localStorage.getItem(KEYWORDS.RANK) || '0', 10)), 68 | not(isNaN), 69 | 0, 70 | () => localStorage.setItem(KEYWORDS.RANK, '0') 71 | ); 72 | this[KEYWORDS.TEXT] = query[KEYWORDS.TEXT] || localStorage.getItem(KEYWORDS.TEXT) || ''; 73 | this[KEYWORDS.TAGS] = isOrDefault( 74 | query[KEYWORDS.TAGS] 75 | ? query[KEYWORDS.TAGS].split(',').filter(Boolean) 76 | : parseJson(localStorage.getItem(KEYWORDS.TAGS)), 77 | Array.isArray, 78 | [], 79 | () => localStorage.setItem(KEYWORDS.TAGS, '[]') 80 | ); 81 | this[KEYWORDS.LANG] = isOrDefault( 82 | query[KEYWORDS.LANG] || localStorage.getItem(KEYWORDS.LANG), 83 | oneOf(ALL_LANGS), 84 | 'en', 85 | () => localStorage.setItem(KEYWORDS.LANG, 'en') 86 | ); 87 | this[KEYWORDS.ORDER] = isOrDefault( 88 | query[KEYWORDS.ORDER] || localStorage.getItem(KEYWORDS.ORDER), 89 | oneOf(ALL_ORDERS), 90 | 'date-DESC', 91 | () => localStorage.setItem(KEYWORDS.ORDER, 'date-DESC') 92 | ); 93 | }, 94 | setLang(lang) { 95 | this[ KEYWORDS.LANG ] = lang; 96 | _saveChanges(KEYWORDS.LANG, lang); 97 | }, 98 | setOrder(order) { 99 | this[ KEYWORDS.ORDER ] = order; 100 | _saveChanges(KEYWORDS.ORDER, order); 101 | }, 102 | setTags(tags) { 103 | this[ KEYWORDS.TAGS ] = tags === null ? [] : (Array.isArray(tags) ? tags : []); 104 | _saveChanges(KEYWORDS.TAGS, JSON.stringify(this[KEYWORDS.TAGS])); 105 | }, 106 | setRank(rank) { 107 | this[ KEYWORDS.RANK ] = rank; 108 | _saveChanges(KEYWORDS.RANK, rank); 109 | }, 110 | setSearchText(text) { 111 | this[ KEYWORDS.TEXT ] = text; 112 | _saveChanges(KEYWORDS.TEXT, text); 113 | } 114 | } 115 | })); -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 56 | 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AIA Podcast's AI Tools Catalog 2 | This is a catalog of tools that have been mentioned in the [AIA Podcast](https://itbeard.com/aia). 3 | These tools are AI-powered and can be useful in programming, content creation, and increasing your effectiveness. 4 | 5 | ## Links 6 | - Catalog: https://awclub.github.io/catalog 7 | - AIA Podcast: https://itbeard.com/aia 8 | - Podcast's Chat: https://t.me/aiapodcast 9 | - Anywhere Club: https://aw.club 10 | 11 | 12 | ## FAQ 13 | ### How do I add my service to the catalog? 14 | Visit our chat on [Telegram](https://t.me/aiapodcast) (RU lang) and send us the link to your service with a short description. If your service is helpful and interesting enough, we will discuss it in the next episode of the AIA Podcast and then add it to our catalog. This is the only way to get into this catalog. 15 | 16 | 17 | ### How can I add improvements to the catalog? 18 | Make a fork of this repository, then make improvements and send a PR (Pull Request) to our repository. Alternatively, you can create an Issue with type "[Feature request](https://github.com/awclub/catalog/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=)", and we will implement them if we decide that they are acceptable and meaningful. 19 | 20 | 21 | ### How can I report a closed or inoperative tool from the catalog? 22 | You can create an Issue with type "[Сlosed or inoperative tool report](https://github.com/awclub/catalog/issues/new?assignees=&labels=closed+tool%2C+inoperative+tool&projects=&template=%D1%81losed-or-inoperative-tool-report.md&title=)". Then, we will review the tool and decide whether to remove it from the catalog or not. 23 | 24 | --- 25 | 26 | # Каталог ИИ-сервисов от AIA Podcast 27 | Это каталог ИИ-сервисов и инструментов, которые упоминались в [AIA Podcast](https://itbeard.com/aia). 28 | Эти инструменты работают на основе искусственного интеллекта и могут быть полезны в программировании, создании контента и повышении эффективности вашей работы. 29 | 30 | ## Ссылки 31 | - Каталок: https://awclub.github.io/catalog 32 | - AIA Podcast: https://itbeard.com/aia 33 | - Чат подкаста: https://t.me/aiapodcast 34 | - Anywhere Club: https://aw.club 35 | 36 | ## Вопрос-ответ 37 | ### Как добавить сервис в каталог? 38 | Зайдите в наш чат в [Telegram](https://t.me/aiapodcast) и пришлите нам ссылку на сервис с кратким описанием. Если сервис окажется достаточно полезным и интересным, мы обсудим его в следующем эпизоде AIA Podcast, а затем добавим в наш каталог. Это единственный способ попасть в каталог. 39 | 40 | ### Как добавить улучшения в каталог? 41 | Сделайте форк этого репозитория, затем внесите улучшения и отправьте PR (Pull Request) в наш репозиторий. Также вы можете создать Issue с типом "[Feature request](https://github.com/awclub/catalog/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=)" и описанием улучшения, мы рассмотрим его и внедрим, если решим, что оно приемлемо и имеет смысл. 42 | 43 | 44 | ### Как сообщить о закрытом или неработающем сервисе из каталога? 45 | Вы можете создать Issue с типом "[Сlosed or inoperative tool report](https://github.com/awclub/catalog/issues/new?assignees=&labels=closed+tool%2C+inoperative+tool&projects=&template=%D1%81losed-or-inoperative-tool-report.md&title=)". Затем мы рассмотрим запрос и решим, удалять сервис из каталога или нет. 46 | 47 | --- 48 | 49 | # How to run the catalog (instructions for developers) 50 | 51 | - The catalogue runs on `Vue 3`, so you need to prepare your local environment. 52 | - We use [ESLint](https://eslint.org/) for linting. 53 | - The database file is located at the path: `public/db.json` 54 | 55 | ## Recommended IDE Setup 56 | 57 | - [VSCode](https://code.visualstudio.com/) 58 | - [Volar Extension](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (also you need to disable Vetur, if you are using it) 59 | - [TypeScript Vue Plugin](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 60 | 61 | ## Customize configuration 62 | 63 | If you need to change the settings, see the [Vite Configuration Reference](https://vitejs.dev/config/). 64 | 65 | ### Project Setup 66 | 67 | ```sh 68 | npm install 69 | ``` 70 | 71 | ### Lint, Compile and Hot-Reload for Development 72 | 73 | ```sh 74 | npm run dev 75 | ``` 76 | 77 | ### Lint, Compile and Minify for Production 78 | 79 | ```sh 80 | npm run build 81 | ``` 82 | -------------------------------------------------------------------------------- /src/components/service_card/ServiceListItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 70 | 71 | --------------------------------------------------------------------------------