├── 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 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
9 |
13 | {{ $t('themeLight') }}
14 |
15 |
19 | {{ $t('themeDark') }}
20 |
21 |
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 |
9 | currentLangStore.setCurrentLang('en')"
13 | >
14 | 🌐 English
15 |
16 | currentLangStore.setCurrentLang('ru')"
20 | >
21 | 🌐 Русский
22 |
23 |
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 |
9 |
10 | onTagClick(tag)"
15 | >{{ tag }}
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/components/ServiceRating.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 | ★
13 | ★
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/Loader.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/views/HomeView.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
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 |
2 |
25 |
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 |
14 |
15 |
22 | ×
27 |
28 |
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 |
14 |
15 |
16 | {{ $t('sortingLegend') }}
17 |
18 |
24 | {{ $t(order.textLabelKey) }}
25 | {{ viewSettings[order.key] === DIRECTION.ASC ? '↑' : '↓' }}
26 |
27 |
28 |
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 |
31 |
32 |
36 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/components/StarFilter.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
38 | ★
39 |
40 |
48 |
49 |
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 |
33 |
39 |
45 |
56 |
57 |
58 |
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/components/settings/Settings.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
18 |
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 |
30 |
33 |
36 |
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 |
29 |
30 |
34 |
48 |
{{ service.description[$i18n.locale] }}
49 |
50 | {{ $t('mentionedIn') }}:
51 |
58 | {{ mention.episodeName }}
59 |
60 |
61 |
62 | {{ tag }}
67 |
68 |
69 | {{ localDateFilter(service.date, $i18n.locale) }}
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/components/search/FilterComponent.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
75 |
76 |
77 |
122 |
123 |
--------------------------------------------------------------------------------
/src/components/search/AutoComplete.vue:
--------------------------------------------------------------------------------
1 |
70 |
71 |
72 |
99 |
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 |
20 |
55 |
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 |
23 |
24 |
48 |
{{ serviceItem.description[$i18n.locale] }}
49 |
50 | {{ $t('mentionedIn') }}:
51 |
58 | {{ mention.episodeName }}
59 |
60 |
61 |
65 |
66 | {{ localDateFilter(serviceItem.date, $i18n.locale) }}
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------