initial commit
This commit is contained in:
commit
9b30f3595d
87 changed files with 12838 additions and 0 deletions
21
.eslintrc.cjs
Normal file
21
.eslintrc.cjs
Normal file
|
@ -0,0 +1,21 @@
|
|||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"@vue/eslint-config-typescript",
|
||||
"@vue/eslint-config-prettier",
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ["cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}"],
|
||||
extends: ["plugin:cypress/recommended"],
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
};
|
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
.envrc
|
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
68
README.md
Normal file
68
README.md
Normal file
|
@ -0,0 +1,68 @@
|
|||
# vue-project
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||
|
||||
```sh
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
|
||||
|
||||
```sh
|
||||
npm run test:e2e:dev
|
||||
```
|
||||
|
||||
This runs the end-to-end tests against the Vite development server.
|
||||
It is much faster than the production build.
|
||||
|
||||
But it's still recommended to test the production build with `test:e2e` before deploying (e.g. in CI environments):
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
8
cypress.config.ts
Normal file
8
cypress.config.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "cypress";
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
specPattern: "cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}",
|
||||
baseUrl: "http://localhost:4173",
|
||||
},
|
||||
});
|
8
cypress/e2e/example.cy.ts
Normal file
8
cypress/e2e/example.cy.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// https://docs.cypress.io/api/introduction/api.html
|
||||
|
||||
describe("My First Test", () => {
|
||||
it("visits the app root url", () => {
|
||||
cy.visit("/");
|
||||
cy.contains("h1", "You did it!");
|
||||
});
|
||||
});
|
10
cypress/e2e/tsconfig.json
Normal file
10
cypress/e2e/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["./**/*", "../support/**/*"],
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"types": ["cypress"]
|
||||
}
|
||||
}
|
5
cypress/fixtures/example.json
Normal file
5
cypress/fixtures/example.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
39
cypress/support/commands.ts
Normal file
39
cypress/support/commands.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
// declare global {
|
||||
// namespace Cypress {
|
||||
// interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
export {};
|
20
cypress/support/e2e.ts
Normal file
20
cypress/support/e2e.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import "./commands";
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
1
env.d.ts
vendored
Normal file
1
env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
43
flake.lock
Normal file
43
flake.lock
Normal file
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1667395993,
|
||||
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1674487464,
|
||||
"narHash": "sha256-Jgq50e4S4JVCYpWLqrabBzDp/1mfaxHCh8/OOorHTy0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3954218cf613eba8e0dcefa9abe337d26bc48fd0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
23
flake.nix
Normal file
23
flake.nix
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs-18_x
|
||||
nodePackages.vue-cli
|
||||
nodePackages.vls # vue
|
||||
nodePackages.typescript-language-server # typescript
|
||||
nodePackages.vscode-langservers-extracted # html, css, json, eslint
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
13
index.html
Normal file
13
index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
10854
package-lock.json
generated
Normal file
10854
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
45
package.json
Normal file
45
package.json
Normal file
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "vue-project",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check build-only",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest --environment jsdom --root src/",
|
||||
"test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
|
||||
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.0.29",
|
||||
"vue": "^3.2.45",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@rushstack/eslint-patch": "^1.1.4",
|
||||
"@types/jsdom": "^20.0.1",
|
||||
"@types/node": "^18.11.12",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.0",
|
||||
"@vue/test-utils": "^2.2.6",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"cypress": "^12.0.2",
|
||||
"eslint": "^8.22.0",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-vue": "^9.3.0",
|
||||
"jsdom": "^20.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss-nested": "^6.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"start-server-and-test": "^1.15.2",
|
||||
"typescript": "~4.9.3",
|
||||
"vite": "^4.0.0",
|
||||
"vitest": "^0.25.6",
|
||||
"vue-tsc": "^1.0.12"
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
9
src/App.vue
Normal file
9
src/App.vue
Normal file
|
@ -0,0 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import TheHeader from "@/app/common/components/TheHeader.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TheHeader />
|
||||
|
||||
<RouterView />
|
||||
</template>
|
9
src/app/common/components/TheBrandLogo.vue
Normal file
9
src/app/common/components/TheBrandLogo.vue
Normal file
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<b>SM</b>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
b {
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
33
src/app/common/components/TheHeader.vue
Normal file
33
src/app/common/components/TheHeader.vue
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import TheBrandLogo from "./TheBrandLogo.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="container">
|
||||
<TheBrandLogo class="logo" />
|
||||
<BaseNav>
|
||||
<RouterLink :to="{ name: 'email_files' }">Файлы</RouterLink>
|
||||
<RouterLink :to="{ name: 'audience' }">Аудитория</RouterLink>
|
||||
</BaseNav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
background: var(--color-background-header);
|
||||
padding: 0.7rem 0;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
9
src/app/common/ports.ts
Normal file
9
src/app/common/ports.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import type { Entity } from "@/domain/common/entity";
|
||||
|
||||
export interface BaseFetchManyApiPort<E extends Entity, P = unknown> {
|
||||
fetchMany(props: P): Promise<E[]>;
|
||||
}
|
||||
|
||||
export interface BaseCreateApiPort<E extends Entity, P = unknown> {
|
||||
create(props: P): Promise<E>;
|
||||
}
|
35
src/app/contacts/ContactForm/AudienceContactForm.vue
Normal file
35
src/app/contacts/ContactForm/AudienceContactForm.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, defineEmits } from "vue";
|
||||
import { ContactFormApiCreateProps } from "./ports";
|
||||
import { useContactFormStore } from "./store";
|
||||
|
||||
const store = useContactFormStore();
|
||||
|
||||
const emit = defineEmits(["submitForm"]);
|
||||
|
||||
const contactData = ref({
|
||||
email: "",
|
||||
} as ContactFormApiCreateProps);
|
||||
|
||||
async function submitForm() {
|
||||
await store.create(contactData.value);
|
||||
emit("submitForm");
|
||||
contactData.value = { email: "" };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<label>Email</label>
|
||||
<input v-model="contactData.email" />
|
||||
</div>
|
||||
<BaseButton
|
||||
:isPrimary="true"
|
||||
:isLoading="store.loading"
|
||||
@click="submitForm"
|
||||
>
|
||||
Добавить
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
3
src/app/contacts/ContactForm/index.ts
Normal file
3
src/app/contacts/ContactForm/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as AudienceContactForm } from "./AudienceContactForm.vue";
|
||||
export * from "./ports";
|
||||
export { CONTACT_FORM_API_PROVIDE_KEY } from "./store";
|
11
src/app/contacts/ContactForm/ports.ts
Normal file
11
src/app/contacts/ContactForm/ports.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import type { BaseCreateApiPort } from "@/app/common/ports";
|
||||
import type { Contact } from "@/domain/entities/contact";
|
||||
|
||||
export interface ContactFormApiCreateProps {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type ContactFormApiPort = BaseCreateApiPort<
|
||||
Contact,
|
||||
ContactFormApiCreateProps
|
||||
>;
|
27
src/app/contacts/ContactForm/store.ts
Normal file
27
src/app/contacts/ContactForm/store.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { useLoader } from "@/shared/lib/composables/loader";
|
||||
import { validInject } from "@/shared/lib/di";
|
||||
import { reactive } from "vue";
|
||||
import { useContactsStore } from "../store";
|
||||
import type { ContactFormApiCreateProps, ContactFormApiPort } from "./ports";
|
||||
|
||||
export const CONTACT_FORM_API_PROVIDE_KEY = Symbol("ContactFormApi");
|
||||
|
||||
export function useContactFormStore() {
|
||||
const api = validInject<ContactFormApiPort>(
|
||||
CONTACT_FORM_API_PROVIDE_KEY,
|
||||
"ContactFormApiPort"
|
||||
);
|
||||
|
||||
const contactsStore = useContactsStore();
|
||||
|
||||
const loader = useLoader();
|
||||
async function create(data: ContactFormApiCreateProps) {
|
||||
const contact = await loader.wait(api.create(data));
|
||||
contactsStore.addContact(contact);
|
||||
}
|
||||
|
||||
return reactive({
|
||||
loading: loader.loading,
|
||||
create,
|
||||
});
|
||||
}
|
43
src/app/contacts/ContactsTable/AudienceContactsTable.vue
Normal file
43
src/app/contacts/ContactsTable/AudienceContactsTable.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watch } from "vue";
|
||||
import { formatDate } from "@/utils";
|
||||
import { useContactsTableStore } from "./store";
|
||||
|
||||
const columns = computed(() => [
|
||||
{ key: "email", title: "Email" },
|
||||
//{ key: "openedEmailCount", title: "Открыто" },
|
||||
//{ key: "sentEmailCount", title: "Отправлено" },
|
||||
{ key: "createdAt", title: "Добавлен" },
|
||||
]);
|
||||
|
||||
const store = useContactsTableStore();
|
||||
|
||||
const props = defineProps({
|
||||
listId: { type: String, default: "" },
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.listId,
|
||||
(listId, prevListId) => {
|
||||
if (listId === undefined || listId === prevListId) return;
|
||||
return store.fetchMany({ listId: listId ? listId : undefined });
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseTable
|
||||
:loading="store.loading"
|
||||
:columns="columns"
|
||||
:dataItems="store.contacts"
|
||||
>
|
||||
<template #openedEmailCount="{ dataItem }">
|
||||
{{ dataItem.openedEmailCount }}
|
||||
({{ dataItem.openedEmailPercent.toFixed(2) }}%)
|
||||
</template>
|
||||
<template #createdAt="{ dataItem }">
|
||||
{{ formatDate(dataItem.createdAt) }}
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
3
src/app/contacts/ContactsTable/index.ts
Normal file
3
src/app/contacts/ContactsTable/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as AudienceContactsTable } from "./AudienceContactsTable.vue";
|
||||
export * from "./ports";
|
||||
export { CONTACTS_TABLE_API_PROVIDE_KEY } from "./store";
|
12
src/app/contacts/ContactsTable/ports.ts
Normal file
12
src/app/contacts/ContactsTable/ports.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import type { BaseFetchManyApiPort } from "@/app/common/ports";
|
||||
import type { EntityId } from "@/domain/common/entity";
|
||||
import type { Contact } from "@/domain/entities/contact";
|
||||
|
||||
export interface ContactsTableApiFetchManyProps {
|
||||
listId?: EntityId;
|
||||
}
|
||||
|
||||
export type ContactsTableApiPort = BaseFetchManyApiPort<
|
||||
Contact,
|
||||
ContactsTableApiFetchManyProps
|
||||
>;
|
33
src/app/contacts/ContactsTable/store.ts
Normal file
33
src/app/contacts/ContactsTable/store.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useLoader } from "@/shared/lib/composables/loader";
|
||||
import { validInject } from "@/shared/lib/di";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { reactive } from "vue";
|
||||
import { useContactsStore } from "../store";
|
||||
import type {
|
||||
ContactsTableApiFetchManyProps,
|
||||
ContactsTableApiPort,
|
||||
} from "./ports";
|
||||
|
||||
export const CONTACTS_TABLE_API_PROVIDE_KEY = Symbol("ContactsTableApi");
|
||||
|
||||
export function useContactsTableStore() {
|
||||
const api = validInject<ContactsTableApiPort>(
|
||||
CONTACTS_TABLE_API_PROVIDE_KEY,
|
||||
"ContactsTableApiPort"
|
||||
);
|
||||
|
||||
const contactsStore = useContactsStore();
|
||||
const { contacts } = storeToRefs(contactsStore);
|
||||
|
||||
const loader = useLoader();
|
||||
async function fetchMany(query: ContactsTableApiFetchManyProps) {
|
||||
const contacts = await loader.wait(api.fetchMany(query));
|
||||
contactsStore.setContacts(contacts);
|
||||
}
|
||||
|
||||
return reactive({
|
||||
loading: loader.loading,
|
||||
contacts,
|
||||
fetchMany,
|
||||
});
|
||||
}
|
6
src/app/contacts/index.ts
Normal file
6
src/app/contacts/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { ContactFormApiPort } from "./ContactForm";
|
||||
import type { ContactsTableApiPort } from "./ContactsTable";
|
||||
export type { ContactFormApiCreateProps } from "./ContactForm";
|
||||
export type { ContactsTableApiFetchManyProps } from "./ContactsTable";
|
||||
|
||||
export type ContactApiPort = ContactFormApiPort & ContactsTableApiPort;
|
17
src/app/contacts/store.ts
Normal file
17
src/app/contacts/store.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type { Contact } from "@/domain/entities/contact";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useContactsStore = defineStore("contacts", () => {
|
||||
const data = ref([] as Contact[]);
|
||||
|
||||
function setContacts(contacts: Contact[]): void {
|
||||
data.value = contacts;
|
||||
}
|
||||
|
||||
function addContact(contact: Contact): void {
|
||||
data.value.unshift(contact);
|
||||
}
|
||||
|
||||
return { contacts: data, setContacts, addContact };
|
||||
});
|
26
src/app/files/FilesTable/FilesTable.vue
Normal file
26
src/app/files/FilesTable/FilesTable.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useFilesTableStore } from "./store";
|
||||
import { formatDate } from "@/utils";
|
||||
|
||||
const filesStore = useFilesTableStore();
|
||||
|
||||
const columns = computed(() => [
|
||||
{ key: "name", title: "Название" },
|
||||
{ key: "createdAt", title: "Дата создания" },
|
||||
]);
|
||||
|
||||
filesStore.fetchMany();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseTable
|
||||
:loading="filesStore.loading"
|
||||
:columns="columns"
|
||||
:dataItems="filesStore.files"
|
||||
>
|
||||
<template #createdAt="{ dataItem }">
|
||||
{{ formatDate(dataItem.createdAt) }}
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
3
src/app/files/FilesTable/index.ts
Normal file
3
src/app/files/FilesTable/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as FilesTable } from "./FilesTable.vue";
|
||||
export * from "./ports";
|
||||
export { FILES_TABLE_API_PROVIDE_KEY } from "./store";
|
4
src/app/files/FilesTable/ports.ts
Normal file
4
src/app/files/FilesTable/ports.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import type { BaseFetchManyApiPort } from "@/app/common/ports";
|
||||
import type { AppFile } from "@/domain/entities/file";
|
||||
|
||||
export type FilesTableApiPort = BaseFetchManyApiPort<AppFile>;
|
30
src/app/files/FilesTable/store.ts
Normal file
30
src/app/files/FilesTable/store.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useLoader } from "@/shared/lib/composables/loader";
|
||||
import { validInject } from "@/shared/lib/di";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { reactive } from "vue";
|
||||
import { useFilesStore } from "../store";
|
||||
import type { FilesTableApiPort } from "./ports";
|
||||
|
||||
export const FILES_TABLE_API_PROVIDE_KEY = Symbol("FilesTableApi");
|
||||
|
||||
export function useFilesTableStore() {
|
||||
const api = validInject<FilesTableApiPort>(
|
||||
FILES_TABLE_API_PROVIDE_KEY,
|
||||
"ContactsTableApiPort"
|
||||
);
|
||||
|
||||
const filesStore = useFilesStore();
|
||||
const { files } = storeToRefs(filesStore);
|
||||
|
||||
const loader = useLoader();
|
||||
async function fetchMany() {
|
||||
const files = await loader.wait(api.fetchMany({}));
|
||||
filesStore.setFiles(files);
|
||||
}
|
||||
|
||||
return reactive({
|
||||
loading: loader.loading,
|
||||
files,
|
||||
fetchMany,
|
||||
});
|
||||
}
|
3
src/app/files/index.ts
Normal file
3
src/app/files/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import type { FilesTableApiPort } from "./FilesTable";
|
||||
|
||||
export type FileApiPort = FilesTableApiPort;
|
17
src/app/files/store.ts
Normal file
17
src/app/files/store.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type { AppFile } from "@/domain/entities/file";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useFilesStore = defineStore("files", () => {
|
||||
const data = ref([] as AppFile[]);
|
||||
|
||||
function setFiles(files: AppFile[]): void {
|
||||
data.value = files;
|
||||
}
|
||||
|
||||
function addFile(file: AppFile): void {
|
||||
data.value.unshift(file);
|
||||
}
|
||||
|
||||
return { files: data, setFiles, addFile };
|
||||
});
|
35
src/app/lists/ListForm/AudienceListForm.vue
Normal file
35
src/app/lists/ListForm/AudienceListForm.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, defineEmits } from "vue";
|
||||
import { useListFormStore } from "./store";
|
||||
import type { CreateListData } from "@/app_core/ports/list";
|
||||
|
||||
const emit = defineEmits(["submitForm"]);
|
||||
|
||||
const store = useListFormStore();
|
||||
|
||||
const listData = ref({
|
||||
displayName: "",
|
||||
} as CreateListData);
|
||||
|
||||
async function submitForm() {
|
||||
await store.create(listData.value);
|
||||
emit("submitForm");
|
||||
listData.value = { displayName: "" };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<label>Отображаемое имя</label>
|
||||
<input v-model="listData.displayName" />
|
||||
</div>
|
||||
<BaseButton
|
||||
:isPrimary="true"
|
||||
:isLoading="store.loading"
|
||||
@click="submitForm"
|
||||
>
|
||||
Добавить
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
3
src/app/lists/ListForm/index.ts
Normal file
3
src/app/lists/ListForm/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as AudienceListForm } from "./AudienceListForm.vue";
|
||||
export * from "./ports";
|
||||
export { LIST_FORM_API_PROVIDE_KEY } from "./store";
|
8
src/app/lists/ListForm/ports.ts
Normal file
8
src/app/lists/ListForm/ports.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { BaseCreateApiPort } from "@/app/common/ports";
|
||||
import type { List } from "@/domain/entities/list";
|
||||
|
||||
export interface ListFormApiCreateProps {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export type ListFormApiPort = BaseCreateApiPort<List, ListFormApiCreateProps>;
|
27
src/app/lists/ListForm/store.ts
Normal file
27
src/app/lists/ListForm/store.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { useLoader } from "@/shared/lib/composables/loader";
|
||||
import { validInject } from "@/shared/lib/di";
|
||||
import { reactive } from "vue";
|
||||
import { useListsStore } from "../store";
|
||||
import type { ListFormApiCreateProps, ListFormApiPort } from "./ports";
|
||||
|
||||
export const LIST_FORM_API_PROVIDE_KEY = Symbol("ListFormApi");
|
||||
|
||||
export function useListFormStore() {
|
||||
const api = validInject<ListFormApiPort>(
|
||||
LIST_FORM_API_PROVIDE_KEY,
|
||||
"ListFormApiPort"
|
||||
);
|
||||
|
||||
const listsStore = useListsStore();
|
||||
|
||||
const loader = useLoader();
|
||||
async function create(data: ListFormApiCreateProps) {
|
||||
const list = await loader.wait(api.create(data));
|
||||
listsStore.addList(list);
|
||||
}
|
||||
|
||||
return reactive({
|
||||
loading: loader.loading,
|
||||
create,
|
||||
});
|
||||
}
|
36
src/app/lists/ListsSelect/AudienceListsSelect.vue
Normal file
36
src/app/lists/ListsSelect/AudienceListsSelect.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useListsSelectStore } from "./store";
|
||||
|
||||
const store = useListsSelectStore();
|
||||
|
||||
const props = defineProps({
|
||||
extraItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// define props for the v-model directive
|
||||
moduleValue: String,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:moduleValue"]);
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.moduleValue;
|
||||
},
|
||||
set(value) {
|
||||
emit("update:moduleValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
const selectListItems = computed(() => {
|
||||
return props.extraItems.concat(store.selectItems);
|
||||
});
|
||||
|
||||
store.fetchMany();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSelect v-model="value" :items="selectListItems" />
|
||||
</template>
|
3
src/app/lists/ListsSelect/index.ts
Normal file
3
src/app/lists/ListsSelect/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as AudienceListsSelect } from "./AudienceListsSelect.vue";
|
||||
export * from "./ports";
|
||||
export { LISTS_SELECT_API_PROVIDE_KEY } from "./store";
|
4
src/app/lists/ListsSelect/ports.ts
Normal file
4
src/app/lists/ListsSelect/ports.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import type { BaseFetchManyApiPort } from "@/app/common/ports";
|
||||
import type { List } from "@/domain/entities/list";
|
||||
|
||||
export type ListsSelectApiPort = BaseFetchManyApiPort<List>;
|
35
src/app/lists/ListsSelect/store.ts
Normal file
35
src/app/lists/ListsSelect/store.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { useLoader } from "@/shared/lib/composables/loader";
|
||||
import { validInject } from "@/shared/lib/di";
|
||||
import { computed, reactive } from "vue";
|
||||
import { useListsStore } from "../store";
|
||||
import type { ListsSelectApiPort } from "./ports";
|
||||
|
||||
export const LISTS_SELECT_API_PROVIDE_KEY = Symbol("ListsTableApi");
|
||||
|
||||
export function useListsSelectStore() {
|
||||
const api = validInject<ListsSelectApiPort>(
|
||||
LISTS_SELECT_API_PROVIDE_KEY,
|
||||
"ListsSelectApiPort"
|
||||
);
|
||||
|
||||
const listsStore = useListsStore();
|
||||
|
||||
const selectItems = computed(() =>
|
||||
listsStore.lists.map((l) => ({
|
||||
value: l.id,
|
||||
label: l.displayName,
|
||||
}))
|
||||
);
|
||||
|
||||
const loader = useLoader();
|
||||
async function fetchMany() {
|
||||
const lists = await loader.wait(api.fetchMany({}));
|
||||
listsStore.setLists(lists);
|
||||
}
|
||||
|
||||
return reactive({
|
||||
loading: loader.loading,
|
||||
selectItems,
|
||||
fetchMany,
|
||||
});
|
||||
}
|
26
src/app/lists/ListsTable/AudienceListsTable.vue
Normal file
26
src/app/lists/ListsTable/AudienceListsTable.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useListsTableStore } from "./store";
|
||||
|
||||
const store = useListsTableStore();
|
||||
|
||||
const columns = computed(() => [{ key: "displayName", title: "Название" }]);
|
||||
|
||||
store.fetchMany();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseTable
|
||||
:loading="store.loading"
|
||||
:columns="columns"
|
||||
:dataItems="store.lists"
|
||||
>
|
||||
<template #displayName="{ dataItem }">
|
||||
<RouterLink
|
||||
:to="{ name: 'audience_contacts', params: { listId: dataItem.id } }"
|
||||
>
|
||||
{{ dataItem.displayName }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
3
src/app/lists/ListsTable/index.ts
Normal file
3
src/app/lists/ListsTable/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as AudienceListsTable } from "./AudienceListsTable.vue";
|
||||
export * from "./ports";
|
||||
export { LISTS_TABLE_API_PROVIDE_KEY } from "./store";
|
4
src/app/lists/ListsTable/ports.ts
Normal file
4
src/app/lists/ListsTable/ports.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import type { BaseFetchManyApiPort } from "@/app/common/ports";
|
||||
import type { List } from "@/domain/entities/list";
|
||||
|
||||
export type ListsTableApiPort = BaseFetchManyApiPort<List>;
|
30
src/app/lists/ListsTable/store.ts
Normal file
30
src/app/lists/ListsTable/store.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useLoader } from "@/shared/lib/composables/loader";
|
||||
import { validInject } from "@/shared/lib/di";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { reactive } from "vue";
|
||||
import { useListsStore } from "../store";
|
||||
import type { ListsTableApiPort } from "./ports";
|
||||
|
||||
export const LISTS_TABLE_API_PROVIDE_KEY = Symbol("ListsTableApi");
|
||||
|
||||
export function useListsTableStore() {
|
||||
const api = validInject<ListsTableApiPort>(
|
||||
LISTS_TABLE_API_PROVIDE_KEY,
|
||||
"ContactsTableApiPort"
|
||||
);
|
||||
|
||||
const listsStore = useListsStore();
|
||||
const { lists } = storeToRefs(listsStore);
|
||||
|
||||
const loader = useLoader();
|
||||
async function fetchMany() {
|
||||
const lists = await loader.wait(api.fetchMany({}));
|
||||
listsStore.setLists(lists);
|
||||
}
|
||||
|
||||
return reactive({
|
||||
loading: loader.loading,
|
||||
lists,
|
||||
fetchMany,
|
||||
});
|
||||
}
|
8
src/app/lists/index.ts
Normal file
8
src/app/lists/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { ListsTableApiPort } from "./ListsTable";
|
||||
import type { ListsSelectApiPort } from "./ListsSelect";
|
||||
import type { ListFormApiPort } from "./ListForm";
|
||||
export type { ListFormApiCreateProps } from "./ListForm";
|
||||
|
||||
export type ListApiPort = ListsTableApiPort &
|
||||
ListsSelectApiPort &
|
||||
ListFormApiPort;
|
17
src/app/lists/store.ts
Normal file
17
src/app/lists/store.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type { List } from "@/domain/entities/list";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useListsStore = defineStore("lists", () => {
|
||||
const data = ref([] as List[]);
|
||||
|
||||
function setLists(lists: List[]): void {
|
||||
data.value = lists;
|
||||
}
|
||||
|
||||
function addList(list: List): void {
|
||||
data.value.unshift(list);
|
||||
}
|
||||
|
||||
return { lists: data, setLists, addList };
|
||||
});
|
176
src/assets/base.css
Normal file
176
src/assets/base.css
Normal file
|
@ -0,0 +1,176 @@
|
|||
/* catppuccin latte color palette from <https://github.com/catppuccin/catppuccin> */
|
||||
:root {
|
||||
/* Rosewater #dc8a78 rgb(220, 138, 120) hsl(11, 59%, 67%) */
|
||||
/* Flamingo #dd7878 rgb(221, 120, 120) hsl(0, 60%, 67%) */
|
||||
/* Pink #ea76cb rgb(234, 118, 203) hsl(316, 73%, 69%) */
|
||||
/* Mauve #8839ef rgb(136, 57, 239) hsl(266, 85%, 58%) */
|
||||
/* Red #d20f39 rgb(210, 15, 57) hsl(347, 87%, 44%) */
|
||||
/* Maroon #e64553 rgb(230, 69, 83) hsl(355, 76%, 59%) */
|
||||
/* Peach #fe640b rgb(254, 100, 11) hsl(22, 99%, 52%) */
|
||||
/* Yellow #df8e1d rgb(223, 142, 29) hsl(35, 77%, 49%) */
|
||||
/* Green #40a02b rgb(64, 160, 43) hsl(109, 58%, 40%) */
|
||||
/* Teal #179299 rgb(23, 146, 153) hsl(183, 74%, 35%) */
|
||||
/* Sky #04a5e5 rgb(4, 165, 229) hsl(197, 97%, 46%) */
|
||||
/* Sapphire #209fb5 rgb(32, 159, 181) hsl(189, 70%, 42%) */
|
||||
/* Blue #1e66f5 rgb(30, 102, 245) hsl(220, 91%, 54%) */
|
||||
/* Lavender #7287fd rgb(114, 135, 253) hsl(231, 97%, 72%) */
|
||||
/* Text #4c4f69 rgb(76, 79, 105) hsl(234, 16%, 35%) */
|
||||
/* Subtext1 #5c5f77 rgb(92, 95, 119) hsl(233, 13%, 41%) */
|
||||
/* Subtext0 #6c6f85 rgb(108, 111, 133) hsl(233, 10%, 47%) */
|
||||
/* Overlay2 #7c7f93 rgb(124, 127, 147) hsl(232, 10%, 53%) */
|
||||
/* Overlay1 #8c8fa1 rgb(140, 143, 161) hsl(231, 10%, 59%) */
|
||||
/* Overlay0 #9ca0b0 rgb(156, 160, 176) hsl(228, 11%, 65%) */
|
||||
/* Surface2 #acb0be rgb(172, 176, 190) hsl(227, 12%, 71%) */
|
||||
/* Surface1 #bcc0cc rgb(188, 192, 204) hsl(225, 14%, 77%) */
|
||||
/* Surface0 #ccd0da rgb(204, 208, 218) hsl(223, 16%, 83%) */
|
||||
/* Base #eff1f5 rgb(239, 241, 245) hsl(220, 23%, 95%) */
|
||||
/* Mantle #e6e9ef rgb(230, 233, 239) hsl(220, 22%, 92%) */
|
||||
/* Crust #dce0e8 rgb(220, 224, 232) hsl(220, 21%, 89%) */
|
||||
|
||||
--latte-rosewater: hsl(11, 59%, 67%);
|
||||
--latte-flamingo: hsl(0, 60%, 67%);
|
||||
--latte-pink: hsl(316, 73%, 69%);
|
||||
--latte-mauve: hsl(266, 85%, 58%);
|
||||
--latte-red: hsl(347, 87%, 44%);
|
||||
--latte-maroon: hsl(355, 76%, 59%);
|
||||
--latte-peach: hsl(22, 99%, 52%);
|
||||
--latte-yellow: hsl(35, 77%, 49%);
|
||||
--latte-green: hsl(109, 58%, 40%);
|
||||
--latte-teal: hsl(183, 74%, 35%);
|
||||
--latte-sky: hsl(197, 97%, 46%);
|
||||
--latte-sapphire: hsl(189, 70%, 42%);
|
||||
--latte-blue: hsl(220, 91%, 54%);
|
||||
--latte-lavender: hsl(231, 97%, 72%);
|
||||
--latte-text: hsl(234, 16%, 35%);
|
||||
--latte-subtext1: hsl(233, 13%, 41%);
|
||||
--latte-subtext0: hsl(233, 10%, 47%);
|
||||
--latte-overlay2: hsl(232, 10%, 53%);
|
||||
--latte-overlay1: hsl(231, 10%, 59%);
|
||||
--latte-overlay0: hsl(228, 11%, 65%);
|
||||
--latte-surface2: hsl(227, 12%, 71%);
|
||||
--latte-surface1: hsl(225, 14%, 77%);
|
||||
--latte-surface0: hsl(223, 16%, 83%);
|
||||
--latte-base: hsl(220, 23%, 95%);
|
||||
--latte-mantle: hsl(220, 22%, 92%);
|
||||
--latte-crust: hsl(220, 21%, 89%);
|
||||
}
|
||||
|
||||
/* catppuccin frappe color palette from <https://github.com/catppuccin/catppuccin> */
|
||||
:root {
|
||||
/* Rosewater #f2d5cf rgb(242, 213, 207) hsl(10, 57%, 88%) */
|
||||
/* Flamingo #eebebe rgb(238, 190, 190) hsl(0, 59%, 84%) */
|
||||
/* Pink #f4b8e4 rgb(244, 184, 228) hsl(316, 73%, 84%) */
|
||||
/* Mauve #ca9ee6 rgb(202, 158, 230) hsl(277, 59%, 76%) */
|
||||
/* Red #e78284 rgb(231, 130, 132) hsl(359, 68%, 71%) */
|
||||
/* Maroon #ea999c rgb(234, 153, 156) hsl(358, 66%, 76%) */
|
||||
/* Peach #ef9f76 rgb(239, 159, 118) hsl(20, 79%, 70%) */
|
||||
/* Yellow #e5c890 rgb(229, 200, 144) hsl(40, 62%, 73%) */
|
||||
/* Green #a6d189 rgb(166, 209, 137) hsl(96, 44%, 68%) */
|
||||
/* Teal #81c8be rgb(129, 200, 190) hsl(172, 39%, 65%) */
|
||||
/* Sky #99d1db rgb(153, 209, 219) hsl(189, 48%, 73%) */
|
||||
/* Sapphire #85c1dc rgb(133, 193, 220) hsl(199, 55%, 69%) */
|
||||
/* Blue #8caaee rgb(140, 170, 238) hsl(222, 74%, 74%) */
|
||||
/* Lavender #babbf1 rgb(186, 187, 241) hsl(239, 66%, 84%) */
|
||||
/* Text #c6d0f5 rgb(198, 208, 245) hsl(227, 70%, 87%) */
|
||||
/* Subtext1 #b5bfe2 rgb(181, 191, 226) hsl(227, 44%, 80%) */
|
||||
/* Subtext0 #a5adce rgb(165, 173, 206) hsl(228, 29%, 73%) */
|
||||
/* Overlay2 #949cbb rgb(148, 156, 187) hsl(228, 22%, 66%) */
|
||||
/* Overlay1 #838ba7 rgb(131, 139, 167) hsl(227, 17%, 58%) */
|
||||
/* Overlay0 #737994 rgb(115, 121, 148) hsl(229, 13%, 52%) */
|
||||
/* Surface2 #626880 rgb(98, 104, 128) hsl(228, 13%, 44%) */
|
||||
/* Surface1 #51576d rgb(81, 87, 109) hsl(227, 15%, 37%) */
|
||||
/* Surface0 #414559 rgb(65, 69, 89) hsl(230, 16%, 30%) */
|
||||
/* Base #303446 rgb(48, 52, 70) hsl(229, 19%, 23%) */
|
||||
/* Mantle #292c3c rgb(41, 44, 60) hsl(231, 19%, 20%) */
|
||||
/* Crust #232634 rgb(35, 38, 52) hsl(229, 20%, 17%) */
|
||||
|
||||
--frappe-rosewater: hsl(10, 57%, 88%);
|
||||
--frappe-flamingo: hsl(0, 59%, 84%);
|
||||
--frappe-pink: hsl(316, 73%, 84%);
|
||||
--frappe-mauve: hsl(277, 59%, 76%);
|
||||
--frappe-red: hsl(359, 68%, 71%);
|
||||
--frappe-maroon: hsl(358, 66%, 76%);
|
||||
--frappe-peach: hsl(20, 79%, 70%);
|
||||
--frappe-yellow: hsl(40, 62%, 73%);
|
||||
--frappe-green: hsl(96, 44%, 68%);
|
||||
--frappe-teal: hsl(172, 39%, 65%);
|
||||
--frappe-sky: hsl(189, 48%, 73%);
|
||||
--frappe-sapphire: hsl(199, 55%, 69%);
|
||||
--frappe-blue: hsl(222, 74%, 74%);
|
||||
--frappe-lavender: hsl(239, 66%, 84%);
|
||||
--frappe-text: hsl(227, 70%, 87%);
|
||||
--frappe-subtext1: hsl(227, 44%, 80%);
|
||||
--frappe-subtext0: hsl(228, 29%, 73%);
|
||||
--frappe-overlay2: hsl(228, 22%, 66%);
|
||||
--frappe-overlay1: hsl(227, 17%, 58%);
|
||||
--frappe-overlay0: hsl(229, 13%, 52%);
|
||||
--frappe-surface2: hsl(228, 13%, 44%);
|
||||
--frappe-surface1: hsl(227, 15%, 37%);
|
||||
--frappe-surface0: hsl(230, 16%, 30%);
|
||||
--frappe-base: hsl(229, 19%, 23%);
|
||||
--frappe-mantle: hsl(231, 19%, 20%);
|
||||
--frappe-crust: hsl(229, 20%, 17%);
|
||||
}
|
||||
|
||||
/* catppuccin style guide https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md */
|
||||
:root {
|
||||
--color-background: var(--latte-base);
|
||||
--color-background-header: var(--latte-crust);
|
||||
--color-text: var(--latte-text);
|
||||
--color-link: var(--latte-rosewater);
|
||||
|
||||
--color-primary: var(--latte-lavender);
|
||||
|
||||
--color-button-background: var(--latte-surface2);
|
||||
--color-button-text: var(--latte-text);
|
||||
|
||||
--color-spinner: var(--latte-text);
|
||||
|
||||
--color-table-background: var(--latte-surface0);
|
||||
--color-table-border: var(--latte-overlay0);
|
||||
}
|
||||
|
||||
/*@media (prefers-color-scheme: dark) {*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--frappe-base);
|
||||
--color-background-header: var(--frappe-crust);
|
||||
--color-text: var(--frappe-text);
|
||||
--color-link: var(--frappe-rosewater);
|
||||
|
||||
--color-primary: var(--frappe-lavender);
|
||||
|
||||
--color-button-background: var(--frappe-surface2);
|
||||
--color-button-text: var(--frappe-text);
|
||||
|
||||
--color-spinner: var(--frappe-text);
|
||||
|
||||
--color-table-background: var(--frappe-surface0);
|
||||
--color-table-border: var(--frappe-overlay0);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
font-weight: normal;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition: color 0.5s, background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
After Width: | Height: | Size: 308 B |
42
src/assets/main.css
Normal file
42
src/assets/main.css
Normal file
|
@ -0,0 +1,42 @@
|
|||
@import "./base.css";
|
||||
|
||||
@keyframes spinAround {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
transition: 0.4s;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
}
|
||||
}
|
5
src/domain/common/entity.ts
Normal file
5
src/domain/common/entity.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type EntityId = string;
|
||||
|
||||
export interface Entity {
|
||||
id: EntityId;
|
||||
}
|
6
src/domain/entities/contact.ts
Normal file
6
src/domain/entities/contact.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { Entity } from "../common/entity";
|
||||
|
||||
export interface Contact extends Entity {
|
||||
email: string;
|
||||
createdAt: Date;
|
||||
}
|
6
src/domain/entities/file.ts
Normal file
6
src/domain/entities/file.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { Entity } from "../common/entity";
|
||||
|
||||
export interface AppFile extends Entity {
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
}
|
5
src/domain/entities/list.ts
Normal file
5
src/domain/entities/list.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import type { Entity } from "../common/entity";
|
||||
|
||||
export interface List extends Entity {
|
||||
displayName: string;
|
||||
}
|
76
src/infra/api/contact.ts
Normal file
76
src/infra/api/contact.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import type {
|
||||
ContactApiPort,
|
||||
ContactFormApiCreateProps,
|
||||
ContactsTableApiFetchManyProps,
|
||||
} from "@/app/contacts";
|
||||
import type { EntityId } from "@/domain/common/entity";
|
||||
import type { Contact } from "@/domain/entities/contact";
|
||||
import { delay } from "@/utils";
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
let $instance: MockContactApi | undefined;
|
||||
|
||||
export class MockContactApi implements ContactApiPort {
|
||||
#contactsByListId: Map<EntityId, Contact[]>;
|
||||
|
||||
constructor() {
|
||||
this.#contactsByListId = new Map();
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!$instance) {
|
||||
$instance = new MockContactApi();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
async create(data: ContactFormApiCreateProps): Promise<Contact> {
|
||||
const contact: Contact = {
|
||||
...data,
|
||||
id: faker.internet.email().toLowerCase(),
|
||||
createdAt: faker.date.birthdate(),
|
||||
};
|
||||
this.#getContacts().unshift(contact);
|
||||
await delay(1000);
|
||||
return contact;
|
||||
}
|
||||
|
||||
async fetchMany({
|
||||
listId,
|
||||
}: ContactsTableApiFetchManyProps): Promise<Contact[]> {
|
||||
const contacts = this.#getContacts(listId);
|
||||
await delay(1000);
|
||||
return contacts.concat();
|
||||
}
|
||||
|
||||
#getContacts(listId?: EntityId): Contact[] {
|
||||
const key = listId ?? "";
|
||||
let contacts = this.#contactsByListId.get(key);
|
||||
if (contacts == null) {
|
||||
contacts = genMockContacts();
|
||||
this.#contactsByListId.set(key, contacts);
|
||||
}
|
||||
return contacts;
|
||||
}
|
||||
}
|
||||
|
||||
function genMockContacts() {
|
||||
return Array.from(new Array(20).keys()).map((_): Contact => {
|
||||
const id = faker.datatype.uuid();
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const openedEmailCount = Number(faker.random.numeric());
|
||||
const sentEmailCount = Number(faker.random.numeric(2));
|
||||
const openedEmailPercent = (openedEmailCount / sentEmailCount) * 100;
|
||||
const createdAt = faker.date.birthdate();
|
||||
return {
|
||||
id,
|
||||
email,
|
||||
createdAt,
|
||||
/*
|
||||
openedEmailCount,
|
||||
openedEmailPercent,
|
||||
sentEmailCount,
|
||||
*/
|
||||
};
|
||||
});
|
||||
}
|
31
src/infra/api/file.ts
Normal file
31
src/infra/api/file.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import type { FileApiPort } from "@/app/files";
|
||||
import type { AppFile } from "@/domain/entities/file";
|
||||
import { delay } from "@/utils";
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
let $instance: FileApiPort | undefined;
|
||||
|
||||
export class MockFileApi implements FileApiPort {
|
||||
#files: AppFile[];
|
||||
|
||||
constructor() {
|
||||
this.#files = Array.from(new Array(20).keys()).map((_) => {
|
||||
const id = faker.datatype.uuid();
|
||||
const name = faker.system.fileName();
|
||||
const createdAt = faker.date.birthdate();
|
||||
return { id, name, createdAt };
|
||||
});
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!$instance) {
|
||||
$instance = new MockFileApi();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
async fetchMany(): Promise<AppFile[]> {
|
||||
await delay(1000);
|
||||
return this.#files.concat();
|
||||
}
|
||||
}
|
41
src/infra/api/list.ts
Normal file
41
src/infra/api/list.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import type { ListApiPort, ListFormApiCreateProps } from "@/app/lists";
|
||||
import type { List } from "@/domain/entities/list";
|
||||
import { delay } from "@/utils";
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
let $instance: ListApiPort | undefined;
|
||||
|
||||
export class MockListApi implements ListApiPort {
|
||||
#lists: List[];
|
||||
|
||||
constructor() {
|
||||
this.#lists = Array.from(new Array(20).keys()).map((_) => {
|
||||
const id = faker.datatype.uuid();
|
||||
const displayName = faker.commerce.productName();
|
||||
return { id, displayName };
|
||||
});
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!$instance) {
|
||||
$instance = new MockListApi();
|
||||
}
|
||||
return $instance;
|
||||
}
|
||||
|
||||
async create(createListData: ListFormApiCreateProps): Promise<List> {
|
||||
const list: List = {
|
||||
id: faker.datatype.uuid(),
|
||||
...createListData,
|
||||
};
|
||||
|
||||
this.#lists.unshift(list);
|
||||
await delay(1000);
|
||||
return list;
|
||||
}
|
||||
|
||||
async fetchMany(): Promise<List[]> {
|
||||
await delay(1000);
|
||||
return this.#lists.concat();
|
||||
}
|
||||
}
|
22
src/infra/api/template.ts
Normal file
22
src/infra/api/template.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import type { TemplateApiPort } from "@/app_core/ports/template";
|
||||
import { delay } from "@/utils";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import type { Template } from "../domain/template";
|
||||
|
||||
export class MockTemplateApi implements TemplateApiPort {
|
||||
#templates: Template[];
|
||||
|
||||
constructor() {
|
||||
this.#templates = Array.from(new Array(20).keys()).map((_) => {
|
||||
const id = faker.datatype.uuid();
|
||||
const displayName = faker.lorem.words(3);
|
||||
const createdAt = faker.date.birthdate();
|
||||
return { id, displayName, createdAt };
|
||||
});
|
||||
}
|
||||
|
||||
async fetchMany(): Promise<Template[]> {
|
||||
await delay(1000);
|
||||
return this.#templates.concat();
|
||||
}
|
||||
}
|
28
src/main.ts
Normal file
28
src/main.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
import "./assets/main.css";
|
||||
import {
|
||||
BaseButton,
|
||||
BaseNav,
|
||||
BasePageHeader,
|
||||
BaseSelect,
|
||||
BaseTable,
|
||||
} from "./shared/uikit";
|
||||
|
||||
const pinia = createPinia();
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
app.component("BaseNav", BaseNav);
|
||||
app.component("BaseButton", BaseButton);
|
||||
app.component("BaseSelect", BaseSelect);
|
||||
app.component("BasePageHeader", BasePageHeader);
|
||||
app.component("BaseTable", BaseTable);
|
||||
|
||||
app.mount("#app");
|
66
src/pages/AudienceContactsPage.vue
Normal file
66
src/pages/AudienceContactsPage.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, provide } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import {
|
||||
AudienceListsSelect,
|
||||
LISTS_SELECT_API_PROVIDE_KEY,
|
||||
} from "@/app/lists/ListsSelect";
|
||||
import {
|
||||
AudienceContactsTable,
|
||||
CONTACTS_TABLE_API_PROVIDE_KEY,
|
||||
} from "@/app/contacts/ContactsTable";
|
||||
import { useMeta } from "@/shared/lib/composables/meta";
|
||||
import { MockContactApi } from "@/infra/api/contact";
|
||||
import { MockListApi } from "@/infra/api/list";
|
||||
|
||||
useMeta("Контакты | Аудитория | SM");
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
function openAddContactForm() {
|
||||
router.push({ name: "audience_create_contact" });
|
||||
}
|
||||
|
||||
const selectedListId = ref("");
|
||||
watch(
|
||||
() => route.params.listId,
|
||||
(listId) => {
|
||||
selectedListId.value = listId ?? "";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function selectList(selectedListId) {
|
||||
if (route.params.listId !== selectedListId) {
|
||||
router.push({
|
||||
name: "audience_contacts",
|
||||
params: { listId: selectedListId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const contactApi = MockContactApi.getInstance();
|
||||
provide(CONTACTS_TABLE_API_PROVIDE_KEY, contactApi);
|
||||
|
||||
const listApi = MockListApi.getInstance();
|
||||
provide(LISTS_SELECT_API_PROVIDE_KEY, listApi);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<BasePageHeader>
|
||||
Контакты
|
||||
<template #extra>
|
||||
<BaseButton @click="openAddContactForm">Добавить контакт</BaseButton>
|
||||
<BaseButton :isPrimary="true">Импортировать контакты</BaseButton>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
<AudienceListsSelect
|
||||
:extraItems="[{ value: '', label: 'Все контакты' }]"
|
||||
:moduleValue="selectedListId"
|
||||
@update:moduleValue="selectList"
|
||||
/>
|
||||
<AudienceContactsTable :listId="selectedListId" />
|
||||
</div>
|
||||
</template>
|
32
src/pages/AudienceCreateContactPage.vue
Normal file
32
src/pages/AudienceCreateContactPage.vue
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import { provide } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
AudienceContactForm,
|
||||
CONTACT_FORM_API_PROVIDE_KEY,
|
||||
} from "@/app/contacts/ContactForm";
|
||||
import { useMeta } from "@/shared/lib/composables/meta";
|
||||
import { MockContactApi } from "@/infra/api/contact";
|
||||
|
||||
useMeta("Создание контакта | Аудитория | SM");
|
||||
|
||||
const contactApi = MockContactApi.getInstance();
|
||||
provide(CONTACT_FORM_API_PROVIDE_KEY, contactApi);
|
||||
|
||||
const router = useRouter();
|
||||
function redirectToContacts() {
|
||||
router.push({ name: "audience_contacts" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<BasePageHeader>
|
||||
Создание контакта
|
||||
<template #extra>
|
||||
<BaseButton @click="redirectToContacts">Отменить</BaseButton>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
<AudienceContactForm @submitForm="redirectToContacts" />
|
||||
</div>
|
||||
</template>
|
33
src/pages/AudienceCreateListPage.vue
Normal file
33
src/pages/AudienceCreateListPage.vue
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import { provide } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
AudienceListForm,
|
||||
LIST_FORM_API_PROVIDE_KEY,
|
||||
} from "@/app/lists/ListForm";
|
||||
import { useMeta } from "@/shared/lib/composables/meta";
|
||||
import { MockListApi } from "@/infra/api/list";
|
||||
|
||||
useMeta("Создание списка | Аудитория | SM");
|
||||
|
||||
const listApi = MockListApi.getInstance();
|
||||
provide(LIST_FORM_API_PROVIDE_KEY, listApi);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function redirectToLists() {
|
||||
router.push({ name: "audience_lists" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<BasePageHeader>
|
||||
Создание списка
|
||||
<template #extra>
|
||||
<BaseButton @click="redirectToLists">Отменить</BaseButton>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
<AudienceListForm @submitForm="redirectToLists" />
|
||||
</div>
|
||||
</template>
|
35
src/pages/AudienceListsPage.vue
Normal file
35
src/pages/AudienceListsPage.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { provide } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
AudienceListsTable,
|
||||
LISTS_TABLE_API_PROVIDE_KEY,
|
||||
} from "@/app/lists/ListsTable";
|
||||
import { useMeta } from "@/shared/lib/composables/meta";
|
||||
import { MockListApi } from "@/infra/api/list";
|
||||
|
||||
useMeta("Списки | Аудитория | SM");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function openAddListForm() {
|
||||
router.push({ name: "audience_create_list" });
|
||||
}
|
||||
|
||||
const listsApi = MockListApi.getInstance();
|
||||
provide(LISTS_TABLE_API_PROVIDE_KEY, listsApi);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<BasePageHeader>
|
||||
Списки
|
||||
<template #extra>
|
||||
<BaseButton :isPrimary="true" @click="openAddListForm">
|
||||
Добавить список
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
<AudienceListsTable />
|
||||
</div>
|
||||
</template>
|
14
src/pages/AudiencePage.vue
Normal file
14
src/pages/AudiencePage.vue
Normal file
|
@ -0,0 +1,14 @@
|
|||
<script>
|
||||
import { useMeta } from "@/shared/lib/composables/meta";
|
||||
useMeta("Аудитория | SM");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<BaseNav>
|
||||
<RouterLink :to="{ name: 'audience_contacts' }">Контакты</RouterLink>
|
||||
<RouterLink :to="{ name: 'audience_lists' }">Списки</RouterLink>
|
||||
</BaseNav>
|
||||
</div>
|
||||
<RouterView />
|
||||
</template>
|
26
src/pages/FilesPage.vue
Normal file
26
src/pages/FilesPage.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { provide } from "vue";
|
||||
import {
|
||||
FilesTable,
|
||||
FILES_TABLE_API_PROVIDE_KEY,
|
||||
} from "@/app/files/FilesTable";
|
||||
import { useMeta } from "@/shared/lib/composables/meta";
|
||||
import { MockFileApi } from "@/infra/api/file";
|
||||
|
||||
useMeta("Файлы | SM");
|
||||
|
||||
const fileApi = MockFileApi.getInstance();
|
||||
provide(FILES_TABLE_API_PROVIDE_KEY, fileApi);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<BasePageHeader>
|
||||
Файлы
|
||||
<template #extra>
|
||||
<BaseButton :isPrimary="true">Добавить файл</BaseButton>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
<FilesTable />
|
||||
</div>
|
||||
</template>
|
11
src/pages/NotFoundPage.vue
Normal file
11
src/pages/NotFoundPage.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { useMeta } from "@/shared/lib/composables/meta";
|
||||
useMeta("404 - Страница не найдена | SM");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Страница не найдена</h1>
|
||||
<p>Упс, мы не можем найти эту страницу</p>
|
||||
</div>
|
||||
</template>
|
55
src/router.ts
Normal file
55
src/router.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "dashboard",
|
||||
redirect: { name: "audience" },
|
||||
},
|
||||
{
|
||||
path: "/audience",
|
||||
name: "audience",
|
||||
redirect: { name: "audience_contacts" },
|
||||
component: () => import("./pages/AudiencePage.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "contacts/new",
|
||||
name: "audience_create_contact",
|
||||
component: () => import("./pages/AudienceCreateContactPage.vue"),
|
||||
},
|
||||
{
|
||||
path: "contacts/:listId?",
|
||||
name: "audience_contacts",
|
||||
component: () => import("./pages/AudienceContactsPage.vue"),
|
||||
},
|
||||
{
|
||||
path: "lists/new",
|
||||
name: "audience_create_list",
|
||||
component: () => import("./pages/AudienceCreateListPage.vue"),
|
||||
},
|
||||
{
|
||||
path: "lists",
|
||||
name: "audience_lists",
|
||||
component: () => import("./pages/AudienceListsPage.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/files",
|
||||
name: "email_files",
|
||||
component: () => import("./pages/FilesPage.vue"),
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "not_found",
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import("./pages/NotFoundPage.vue"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
14
src/shared/lib/composables/loader.ts
Normal file
14
src/shared/lib/composables/loader.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { ref } from "vue";
|
||||
|
||||
export function useLoader() {
|
||||
const loading = ref(false);
|
||||
|
||||
async function wait<T>(process: Promise<T>): Promise<T> {
|
||||
loading.value = true;
|
||||
const res = await process;
|
||||
loading.value = false;
|
||||
return res;
|
||||
}
|
||||
|
||||
return { loading, wait };
|
||||
}
|
7
src/shared/lib/composables/meta.ts
Normal file
7
src/shared/lib/composables/meta.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { onMounted } from "vue";
|
||||
|
||||
export function useMeta(title: string) {
|
||||
onMounted(() => {
|
||||
document.title = title;
|
||||
});
|
||||
}
|
15
src/shared/lib/di.ts
Normal file
15
src/shared/lib/di.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { inject } from "vue";
|
||||
|
||||
export function validInject<T>(key: Symbol, interfaceName?: string): T {
|
||||
const api = inject<T>(key);
|
||||
if (api == null) {
|
||||
let errorMessage = `You have to provide "${key.toString()}"`;
|
||||
if (interfaceName) {
|
||||
errorMessage += `, that implement ${interfaceName} interface`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
// TODO: it would be great if we could check the type for the injected object.
|
||||
|
||||
return api;
|
||||
}
|
59
src/shared/uikit/BaseButton.vue
Normal file
59
src/shared/uikit/BaseButton.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isPrimary: boolean;
|
||||
isLoading: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ '--primary': isPrimary, '--loading': isLoading }"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
button {
|
||||
border: none;
|
||||
background: var(--color-button-background);
|
||||
color: var(--color-button-text);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&.--primary {
|
||||
background: var(--color-link);
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
&.--loading {
|
||||
color: transparent !important;
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
left: calc(50% - (1em * 0.5));
|
||||
top: calc(50% - (1em * 0.5));
|
||||
position: absolute !important;
|
||||
animation: spinAround 0.5s infinite linear;
|
||||
border: 2px solid #dbdbdb;
|
||||
border-top-color: rgb(219, 219, 219);
|
||||
border-right-color: rgb(219, 219, 219);
|
||||
border-radius: 9999px;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
31
src/shared/uikit/BaseNav.vue
Normal file
31
src/shared/uikit/BaseNav.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<nav>
|
||||
<slot />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
|
||||
:slotted(a) {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
&.router-link-active {
|
||||
cursor: default;
|
||||
color: var(--color-link);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
18
src/shared/uikit/BasePageHeader.vue
Normal file
18
src/shared/uikit/BasePageHeader.vue
Normal file
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<header>
|
||||
<h1><slot /></h1>
|
||||
<div>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
42
src/shared/uikit/BaseSelect.vue
Normal file
42
src/shared/uikit/BaseSelect.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, PropType } from "vue";
|
||||
|
||||
interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as PropType<SelectItem[]>,
|
||||
required: true,
|
||||
},
|
||||
// Define props for v-model directive
|
||||
modelValue: String,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select v-model="value">
|
||||
<option
|
||||
v-for="selectItem in items"
|
||||
:key="selectItem.value"
|
||||
:value="selectItem.value"
|
||||
:disabled="selectItem.disabled"
|
||||
>
|
||||
{{ selectItem.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
107
src/shared/uikit/BaseTable.vue
Normal file
107
src/shared/uikit/BaseTable.vue
Normal file
|
@ -0,0 +1,107 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, PropType } from "vue";
|
||||
|
||||
interface TableColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
loading: Boolean,
|
||||
columns: {
|
||||
type: Array as PropType<TableColumn[]>,
|
||||
required: true,
|
||||
},
|
||||
dataItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper" :class="{ '--loading': loading }">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
{{ column.title }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(dataItem, index) in dataItems" :key="dataItem.id || index">
|
||||
<td v-for="column in columns" :key="column.key">
|
||||
<slot :name="column.key" :dataItem="dataItem">{{
|
||||
dataItem[column.key]
|
||||
}}</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-table-border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--color-table-background);
|
||||
font-weight: bold;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--color-table-border);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--color-table-background);
|
||||
}
|
||||
|
||||
.--loading {
|
||||
pointer-events: none;
|
||||
|
||||
tbody,
|
||||
thead {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
left: calc(50% - (1em * 0.5));
|
||||
top: 100px;
|
||||
position: absolute !important;
|
||||
animation: spinAround 0.5s infinite linear;
|
||||
border: 2px solid var(--color-spinner);
|
||||
border-top-color: rgb(219, 219, 219);
|
||||
border-right-color: rgb(219, 219, 219);
|
||||
border-radius: 9999px;
|
||||
border-right-color: transparent;
|
||||
border-top-color: transparent;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
width: 2em;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
5
src/shared/uikit/index.ts
Normal file
5
src/shared/uikit/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export { default as BaseButton } from "./BaseButton.vue";
|
||||
export { default as BaseNav } from "./BaseNav.vue";
|
||||
export { default as BasePageHeader } from "./BasePageHeader.vue";
|
||||
export { default as BaseSelect } from "./BaseSelect.vue";
|
||||
export { default as BaseTable } from "./BaseTable.vue";
|
1
src/shims-vue.d.ts
vendored
Normal file
1
src/shims-vue.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
declare module "*.vue";
|
12
src/utils.ts
Normal file
12
src/utils.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// This util is very useful for mocks to emulate http delay
|
||||
export async function delay(timeout: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, timeout));
|
||||
}
|
12
tsconfig.app.json
Normal file
12
tsconfig.app.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
8
tsconfig.config.json
Normal file
8
tsconfig.config.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.config.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
9
tsconfig.vitest.json
Normal file
9
tsconfig.vitest.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
20
vite.config.ts
Normal file
20
vite.config.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [require("postcss-nested")],
|
||||
},
|
||||
},
|
||||
build: { minify: false },
|
||||
});
|
Loading…
Reference in a new issue