mirror of
https://github.com/rekryt/iplist.git
synced 2025-10-12 16:39:35 +03:00
feat: new frontend
This commit is contained in:
475
frontend/components/base/Form.vue
Normal file
475
frontend/components/base/Form.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<script lang="ts" setup>
|
||||
interface Item {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
label: string;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
});
|
||||
|
||||
const { data, pending } = useFetch((process.env.NODE_ENV === 'production' ? '/' : '/api') + '?format=json&data=group', {
|
||||
lazy: true,
|
||||
server: false,
|
||||
default: () => [],
|
||||
});
|
||||
const selected = ref<Item[]>([]);
|
||||
const selectedGroups = ref<Group[]>([]);
|
||||
const selectedExcluded = ref<Item[]>([]);
|
||||
const selectedExcludedGroups = ref<Group[]>([]);
|
||||
const selectedExcludedCIDR4 = ref<string[]>([]);
|
||||
const selectedExcludedIP4 = ref<string[]>([]);
|
||||
const selectedExcludedDomains = ref<string[]>([]);
|
||||
const selectedExcludedIP6 = ref<string[]>([]);
|
||||
const selectedExcludedCIDR6 = ref<string[]>([]);
|
||||
const isWildCard = ref(false);
|
||||
const isFileSave = ref(false);
|
||||
|
||||
const items = computed<Group[]>(() => {
|
||||
return Object.entries(data.value as Record<string, string[]>).reduce<Group[]>((acc, [site, group]) => {
|
||||
let groupObj = acc.find((g) => g.label === group);
|
||||
if (!groupObj) {
|
||||
groupObj = { label: group, items: [] } as Group;
|
||||
acc.push(groupObj);
|
||||
}
|
||||
groupObj.items.push({ label: site, value: site });
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
|
||||
const itemsList = computed(() => {
|
||||
return selectedGroups.value.length > 0
|
||||
? items.value.filter((item) => selectedGroups.value.includes(item.label))
|
||||
: items.value;
|
||||
});
|
||||
|
||||
const itemsExcludedList = computed(() => {
|
||||
return selectedExcludedGroups.value.length > 0
|
||||
? items.value.filter((item) => !selectedExcludedGroups.value.includes(item.label))
|
||||
: items.value;
|
||||
});
|
||||
|
||||
// prettier-ignore
|
||||
const formatList = ref([
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: 'Text', value: 'text', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'Comma', value: 'comma', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'MikroTik Script', value: 'mikrotik', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'SwitchyOmega RuleList', value: 'switchy', dataTypes: ['domains'] },
|
||||
{ label: 'Dnsmasq nfset', value: 'nfset', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'Dnsmasq ipset', value: 'ipset', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'ClashX', value: 'clashx', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'Keenetic KVAS', value: 'kvas', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'Keenetic Routes (.bat)', value: 'bat', dataTypes: ['ip4', 'cidr4'] },
|
||||
{ label: 'Amnezia', value: 'amnezia', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
{ label: 'Proxy auto configuration (PAC)', value: 'pac', dataTypes: ['domains', 'cidr4'] },
|
||||
{ label: 'Custom', value: 'custom', dataTypes: ['cidr4', 'ip4', 'domains', 'cidr6', 'ip6'] },
|
||||
]);
|
||||
const selectedFormat = ref('json');
|
||||
const customTemplate = ref('');
|
||||
|
||||
const dataTypeList = ref([
|
||||
{ label: t('allData'), value: '' },
|
||||
{ label: t('ipZones4'), value: 'cidr4' },
|
||||
{ label: t('ipAddresses4'), value: 'ip4' },
|
||||
{ label: t('domains'), value: 'domains' },
|
||||
{ label: t('ipZones6'), value: 'cidr6' },
|
||||
{ label: t('ipAddresses6'), value: 'ip6' },
|
||||
]);
|
||||
const selectedDataType = ref('');
|
||||
const allowedDataTypesList = computed(() => {
|
||||
const format = formatList.value.find((format) => format.value === selectedFormat.value);
|
||||
return dataTypeList.value.filter((dataType) => !format.dataTypes || format.dataTypes?.includes(dataType.value));
|
||||
});
|
||||
watch(selectedFormat, () => {
|
||||
const format = formatList.value.find((format) => format.value === selectedFormat.value);
|
||||
if (format.dataTypes) {
|
||||
if (!format.dataTypes.includes(selectedDataType.value)) {
|
||||
selectedDataType.value = allowedDataTypesList.value[0].value;
|
||||
}
|
||||
}
|
||||
});
|
||||
const tab = ref('portals');
|
||||
const toQueryParams = (params: Record<string, never>): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const key in params) {
|
||||
const value = params[key];
|
||||
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
parts.push(`${key}=${encodeURIComponent(item)}`);
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
for (const subKey in value) {
|
||||
const subValue = value[subKey];
|
||||
if (subValue !== undefined && subValue !== null) {
|
||||
parts.push(`${key}[${encodeURIComponent(subKey)}]=${encodeURIComponent(subValue)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('&');
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const data = {
|
||||
format: selectedFormat.value,
|
||||
};
|
||||
if (selectedDataType.value) {
|
||||
data['data'] = selectedDataType.value;
|
||||
if (selectedDataType.value === 'domains' && isWildCard.value) {
|
||||
data['wildcard'] = '1';
|
||||
}
|
||||
}
|
||||
if (selected.value.length > 0) {
|
||||
data['site'] = selected.value.map((item) => item.label);
|
||||
}
|
||||
if (selectedGroups.value.length > 0) {
|
||||
data['group'] = selectedGroups.value;
|
||||
}
|
||||
if (selectedFormat.value === 'custom') {
|
||||
data['template'] = customTemplate.value;
|
||||
}
|
||||
|
||||
if (selectedExcluded.value.length > 0) {
|
||||
data['exclude[site]'] = selectedExcluded.value.map((item) => item.label);
|
||||
}
|
||||
if (selectedExcludedGroups.value.length > 0) {
|
||||
data['exclude[group]'] = selectedExcludedGroups.value;
|
||||
}
|
||||
if (selectedDataType.value === 'ip4' && selectedExcludedIP4.value.length > 0) {
|
||||
data['exclude[ip4]'] = selectedExcludedIP4.value;
|
||||
}
|
||||
if (selectedDataType.value === 'ip6' && selectedExcludedIP6.value.length > 0) {
|
||||
data['exclude[ip6]'] = selectedExcludedIP6.value;
|
||||
}
|
||||
if (selectedDataType.value === 'cidr4' && selectedExcludedCIDR4.value.length > 0) {
|
||||
data['exclude[cidr4]'] = selectedExcludedCIDR4.value;
|
||||
}
|
||||
if (selectedDataType.value === 'cidr6' && selectedExcludedCIDR6.value.length > 0) {
|
||||
data['exclude[cidr6]'] = selectedExcludedCIDR6.value;
|
||||
}
|
||||
if (selectedDataType.value === 'domains' && selectedExcludedDomains.value.length > 0) {
|
||||
data['exclude[domain]'] = selectedExcludedDomains.value;
|
||||
}
|
||||
|
||||
if (isFileSave.value) {
|
||||
data['filesave'] = '1';
|
||||
}
|
||||
|
||||
window.location.href = '/?' + toQueryParams(data);
|
||||
};
|
||||
</script>
|
||||
<i18n lang="json">
|
||||
{
|
||||
"en": {
|
||||
"format": "Format",
|
||||
"dataType": "Data type",
|
||||
"template": "Template",
|
||||
"groupName": "Group name",
|
||||
"siteName": "Portal name",
|
||||
"data": "Selected data",
|
||||
"shortmask": "Subnet mask (short) (for IP and CIDR)",
|
||||
"mask": "Subnet mask (full) (for IP and CIDR)",
|
||||
"portals": "Portals",
|
||||
"groups": "Groups",
|
||||
"exclude": "Exclusions",
|
||||
"portalSelection": "Portal selection",
|
||||
"doNotSelectIfNeedAll": "Do not select if you want to get all",
|
||||
"filteredByGroups": "The set of portals is filtered by the selected groups",
|
||||
"groupSelection": "Group selection",
|
||||
"excludePortals": "Exclude portals",
|
||||
"excludeGroups": "Exclude groups",
|
||||
"excludeIpZones": "Exclude IP zones",
|
||||
"excludeIp": "Exclude IP",
|
||||
"excludeDomains": "Exclude domains",
|
||||
"onlyWildcard": "Only wildcard domains",
|
||||
"saveToFile": "Save as file",
|
||||
"submit": "Submit",
|
||||
"allData": "All data",
|
||||
"ipZones4": "IPv4 zones (CIDR)",
|
||||
"ipAddresses4": "IPv4 addresses",
|
||||
"domains": "Domains",
|
||||
"ipZones6": "IPv6 zones (CIDR)",
|
||||
"ipAddresses6": "IPv6 addresses"
|
||||
},
|
||||
"ru": {
|
||||
"format": "Формат",
|
||||
"dataType": "Тип данных",
|
||||
"template": "Шаблон",
|
||||
"groupName": "Имя группы",
|
||||
"siteName": "Имя портала",
|
||||
"data": "Выбранные данные",
|
||||
"shortmask": "Маска подсети (короткая) (для ip и cidr)",
|
||||
"mask": "Маска подсети (полная) (для ip и cidr)",
|
||||
"portals": "Порталы",
|
||||
"groups": "Группы",
|
||||
"exclude": "Исключения",
|
||||
"portalSelection": "Выбор порталов",
|
||||
"doNotSelectIfNeedAll": "Не выбирайте, если хотите получить все",
|
||||
"filteredByGroups": "Набор порталов отфильтрован по выбранным группам",
|
||||
"groupSelection": "Выбор групп",
|
||||
"excludePortals": "Исключить порталы",
|
||||
"excludeGroups": "Исключить группы",
|
||||
"excludeIpZones": "Исключить IP-зоны",
|
||||
"excludeIp": "Исключить IP",
|
||||
"excludeDomains": "Исключить домены",
|
||||
"onlyWildcard": "Только wildcard домены",
|
||||
"saveToFile": "Сохранить как файл",
|
||||
"submit": "Отправить",
|
||||
"allData": "Все данные",
|
||||
"ipZones4": "IP-зоны ipv4 (CIDR)",
|
||||
"ipAddresses4": "IP-адреса ipv4",
|
||||
"domains": "Домены",
|
||||
"ipZones6": "IP-зоны ipv6 (CIDR)",
|
||||
"ipAddresses6": "IP-адреса ipv6"
|
||||
},
|
||||
"cn": {
|
||||
"format": "格式",
|
||||
"dataType": "数据类型",
|
||||
"template": "模板",
|
||||
"groupName": "分组名称",
|
||||
"siteName": "门户名称",
|
||||
"data": "已选数据",
|
||||
"shortmask": "子网掩码(简写)(用于 IP 和 CIDR)",
|
||||
"mask": "子网掩码(完整)(用于 IP 和 CIDR)",
|
||||
"portals": "门户",
|
||||
"groups": "分组",
|
||||
"exclude": "排除项",
|
||||
"portalSelection": "门户选择",
|
||||
"doNotSelectIfNeedAll": "如果需要全部,请不要选择",
|
||||
"filteredByGroups": "门户集合已根据所选分组进行筛选。",
|
||||
"groupSelection": "分组选择",
|
||||
"excludePortals": "排除门户",
|
||||
"excludeGroups": "排除分组",
|
||||
"excludeIpZones": "排除 IP 区域",
|
||||
"excludeIp": "排除 IP",
|
||||
"excludeDomains": "排除域名",
|
||||
"onlyWildcard": "仅限通配符域名",
|
||||
"saveToFile": "保存为文件",
|
||||
"submit": "提交",
|
||||
"allData": "所有数据",
|
||||
"ipZones4": "IPv4 区域(CIDR)",
|
||||
"ipAddresses4": "IPv4 地址",
|
||||
"domains": "域名",
|
||||
"ipZones6": "IPv6 区域(CIDR)",
|
||||
"ipAddresses6": "IPv6 地址"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
<template>
|
||||
<v-form class="baseForm mx-auto">
|
||||
<v-card class="px-4 py-8" elevation="10">
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<v-select
|
||||
v-model="selectedFormat"
|
||||
:items="formatList"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="t('format')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-select
|
||||
v-model="selectedDataType"
|
||||
:items="allowedDataTypesList"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="t('dataType')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col v-if="selectedFormat === 'custom'" cols="12">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="customTemplate"
|
||||
:label="t('template')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="auto" class="d-flex flex-column justify-center pl-0">
|
||||
<v-tooltip interactive>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-icon v-bind="activatorProps" color="tertiary">mdi-help</v-icon>
|
||||
</template>
|
||||
<div class="pa-4">
|
||||
<ul>
|
||||
<li>{group} - {{ t('groupName') }}</li>
|
||||
<li>{site} - {{ t('siteName') }}</li>
|
||||
<li>{data} - {{ t('groupName') }}</li>
|
||||
<li>{shortmask} - {{ t('shortmask') }}</li>
|
||||
<li>{mask} - {{ t('mask') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-tabs v-model="tab" bg-color="primary">
|
||||
<v-tab value="portals">{{ t('portals') }}</v-tab>
|
||||
<v-tab value="groups">{{ t('groups') }}</v-tab>
|
||||
<v-tab value="exclude">{{ t('exclude') }}</v-tab>
|
||||
</v-tabs>
|
||||
<v-card-text>
|
||||
<v-tabs-window v-model="tab">
|
||||
<v-tabs-window-item class="pt-2" value="portals">
|
||||
<base-form-portals
|
||||
v-model="selected"
|
||||
:label="t('portalSelection')"
|
||||
:items="itemsList"
|
||||
:selected-groups="selectedGroups"
|
||||
:hint="
|
||||
selectedGroups.length === 0
|
||||
? t('doNotSelectIfNeedAll')
|
||||
: t('filteredByGroups')
|
||||
"
|
||||
persistent-hint
|
||||
:loading="pending"
|
||||
></base-form-portals>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item class="pt-2" value="groups">
|
||||
<base-form-groups
|
||||
v-model="selectedGroups"
|
||||
:label="t('groupSelection')"
|
||||
:items="items"
|
||||
:hint="selected.length === 0 ? t('doNotSelectIfNeedAll') : ''"
|
||||
:persistent-hint="selected.length === 0"
|
||||
:loading="pending"
|
||||
></base-form-groups>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<v-tabs-window-item class="pt-2" value="exclude">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<base-form-groups
|
||||
v-model="selectedExcludedGroups"
|
||||
:label="t('excludeGroups')"
|
||||
:items="items"
|
||||
:loading="pending"
|
||||
hide-details
|
||||
></base-form-groups>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<base-form-portals
|
||||
v-model="selectedExcluded"
|
||||
:label="t('excludePortals')"
|
||||
:items="itemsExcludedList"
|
||||
:loading="pending"
|
||||
hide-details
|
||||
></base-form-portals>
|
||||
</v-col>
|
||||
<v-col v-if="selectedDataType === 'cidr4'" cols="12">
|
||||
<v-combobox
|
||||
v-model="selectedExcludedCIDR4"
|
||||
:label="t('excludeIpZones') + ' ipv4'"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col v-if="selectedDataType === 'ip4'" cols="12">
|
||||
<v-combobox
|
||||
v-model="selectedExcludedIP4"
|
||||
:label="t('excludeIp') + ' ipv4'"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col v-if="selectedDataType === 'domains'" cols="12">
|
||||
<v-combobox
|
||||
v-model="selectedExcludedDomains"
|
||||
:label="t('excludeDomains')"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col v-if="selectedDataType === 'cidr6'" cols="12">
|
||||
<v-combobox
|
||||
v-model="selectedExcludedCIDR6"
|
||||
:label="t('excludeIpZones') + ' ipv6'"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col v-if="selectedDataType === 'ip6'" cols="12">
|
||||
<v-combobox
|
||||
v-model="selectedExcludedIP6"
|
||||
:label="t('excludeIp') + ' ipv6'"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col class="py-0" cols="12">
|
||||
<v-checkbox
|
||||
v-if="selectedDataType === 'domains'"
|
||||
v-model="isWildCard"
|
||||
:label="t('onlyWildcard')"
|
||||
:value="true"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-checkbox>
|
||||
<v-checkbox
|
||||
v-model="isFileSave"
|
||||
:label="t('saveToFile')"
|
||||
:value="true"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-checkbox>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-btn color="primary" block size="50" @click="submit">{{ t('submit') }}</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.baseForm {
|
||||
max-width: 580px;
|
||||
}
|
||||
</style>
|
81
frontend/components/base/form/groups.vue
Normal file
81
frontend/components/base/form/groups.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
});
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
persistentHint: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideDetails: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const selected = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<i18n lang="json">
|
||||
{
|
||||
"en": {
|
||||
"allGroups": "All groups",
|
||||
"noData": "Not found"
|
||||
},
|
||||
"ru": {
|
||||
"allGroups": "Все группы",
|
||||
"noData": "Не найдено"
|
||||
},
|
||||
"cn": {
|
||||
"allGroups": "所有分组",
|
||||
"noData": "未找到"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
:label="label"
|
||||
item-title="label"
|
||||
item-value="label"
|
||||
variant="outlined"
|
||||
:placeholder="t('allGroups')"
|
||||
:no-data-text="t('noData')"
|
||||
:hint="hint"
|
||||
:persistent-hint="persistentHint"
|
||||
:loading="loading"
|
||||
:hide-details="hideDetails"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
></v-autocomplete>
|
||||
</template>
|
121
frontend/components/base/form/portals.vue
Normal file
121
frontend/components/base/form/portals.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
});
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
persistentHint: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideDetails: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const selected = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const setSelect = (subItem: { label: string; value: string }): void => {
|
||||
if (selected.value.includes(subItem as never)) {
|
||||
selected.value.splice(selected.value.indexOf(subItem as never), 1);
|
||||
} else {
|
||||
selected.value.push(subItem);
|
||||
}
|
||||
};
|
||||
const setSelectGroup = (item: { label: string; items: { label: string; value: string }[] }): void => {
|
||||
if (item.items.every((subItem) => selected.value.includes(subItem as never))) {
|
||||
item.items.forEach((subItem) => selected.value.splice(selected.value.indexOf(subItem as never), 1));
|
||||
} else {
|
||||
item.items.forEach((subItem: { label: string; value: string }) => {
|
||||
if (!selected.value.includes(subItem as never)) {
|
||||
selected.value.push(subItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<i18n lang="json">
|
||||
{
|
||||
"en": {
|
||||
"allPortals": "All portals",
|
||||
"noData": "Not found"
|
||||
},
|
||||
"ru": {
|
||||
"allPortals": "Все порталы",
|
||||
"noData": "Не найдено"
|
||||
},
|
||||
"cn": {
|
||||
"allPortals": "所有门户",
|
||||
"noData": "未找到"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
:label="label"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
item-children="items"
|
||||
variant="outlined"
|
||||
:placeholder="t('allPortals')"
|
||||
:no-data-text="t('noData')"
|
||||
:hint="hint"
|
||||
:persistent-hint="persistentHint"
|
||||
:loading="loading"
|
||||
:hide-details="hideDetails"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<v-list lines="one" select-strategy="classic">
|
||||
<v-list-item class="font-weight-bold" density="compact" @click="() => setSelectGroup(item.raw)">
|
||||
{{ item.raw.label }}
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-for="(subItem, index) in item.raw.items"
|
||||
:key="index"
|
||||
:tabindex="index"
|
||||
:value="`nestedList${index}`"
|
||||
:active="selected.includes(subItem)"
|
||||
density="compact"
|
||||
@click="() => setSelect(subItem)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ subItem.label }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
206
frontend/components/core/AppBar.vue
Normal file
206
frontend/components/core/AppBar.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<v-app-bar id="core-app-bar" absolute color="transparent" flat height="88">
|
||||
<v-toolbar-title class="font-weight-light align-self-center text-no-wrap">
|
||||
<v-btn v-show="!responsive" icon @click.stop="onClick">
|
||||
<v-icon>mdi-view-list</v-icon>
|
||||
</v-btn>
|
||||
{{ title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-toolbar-items class="flex-fill">
|
||||
<v-row align="center" justify="end" class="mx-0 px-4">
|
||||
<v-col class="px-0 d-block d-md-none" cols="auto">
|
||||
<github-button
|
||||
class="d-block mt-1"
|
||||
href="https://github.com/rekryt/iplist"
|
||||
:data-color-scheme="theme.name.value"
|
||||
data-icon="octicon-star"
|
||||
data-size="small"
|
||||
aria-label="Star rekryt/iplist on GitHub"
|
||||
>
|
||||
Star
|
||||
</github-button>
|
||||
</v-col>
|
||||
<v-col class="d-none d-md-block" cols="auto">
|
||||
<github-button
|
||||
class="d-block mt-1"
|
||||
href="https://github.com/rekryt/iplist"
|
||||
:data-color-scheme="theme.name.value"
|
||||
data-icon="octicon-star"
|
||||
data-size="large"
|
||||
data-show-count="true"
|
||||
aria-label="Star rekryt/iplist on GitHub"
|
||||
>
|
||||
Star
|
||||
</github-button>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-select
|
||||
v-model="locale"
|
||||
:items="localesList"
|
||||
item-title="label"
|
||||
item-value="code"
|
||||
:label="t('language')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="w-32"
|
||||
hide-details
|
||||
@update:model-value="setLocale"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="20" class="mr-2">
|
||||
<img :src="item.raw.flag" alt="flag" />
|
||||
</v-avatar>
|
||||
<span class="d-none d-md-block">{{ item.raw.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item="{ item, props }">
|
||||
<v-list-item v-bind="props">
|
||||
<template #prepend>
|
||||
<v-avatar size="20">
|
||||
<img :src="item.raw.flag" alt="flag" />
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-btn height="48" icon @click="toggleTheme">
|
||||
<v-icon color="tertiary">mdi-theme-light-dark</v-icon>
|
||||
</v-btn>
|
||||
</v-row>
|
||||
</v-toolbar-items>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
<i18n lang="json">
|
||||
{
|
||||
"en": {
|
||||
"language": "Language",
|
||||
"index___en": "Portals",
|
||||
"about___en": "About",
|
||||
"groups___en": "Groups"
|
||||
},
|
||||
"ru": {
|
||||
"language": "Язык",
|
||||
"index___ru": "Порталы",
|
||||
"about___ru": "О проекте",
|
||||
"groups___ru": "Группы"
|
||||
},
|
||||
"cn": {
|
||||
"language": "语言",
|
||||
"index___cn": "通过门户",
|
||||
"about___cn": "关于项目",
|
||||
"groups___cn": "分组"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
<script>
|
||||
// Utilities
|
||||
import { mapActions } from 'pinia';
|
||||
import { useAppStore } from '~/stores/app';
|
||||
import { useDisplay, useTheme } from 'vuetify';
|
||||
import GithubButton from 'vue-github-button';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GithubButton,
|
||||
},
|
||||
setup() {
|
||||
const { t, locale, locales } = useI18n({
|
||||
useScope: 'local',
|
||||
});
|
||||
const theme = useTheme();
|
||||
const cookieTheme = useCookieTheme();
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
const localsData = [
|
||||
{ code: 'en', language: 'English', flag: 'https://flagcdn.com/w40/us.png' },
|
||||
{ code: 'ru', language: 'Русский', flag: 'https://flagcdn.com/w40/ru.png' },
|
||||
{ code: 'cn', language: '简体中文', flag: 'https://flagcdn.com/w40/cn.png' },
|
||||
];
|
||||
const localesList = computed(() =>
|
||||
locales.value.map((l) => ({
|
||||
value: l,
|
||||
code: l.code,
|
||||
label: localsData.find((d) => d.code === l.code).language,
|
||||
flag: localsData.find((d) => d.code === l.code).flag,
|
||||
}))
|
||||
);
|
||||
const localeData = computed(() => localsData.find((l) => l.code === locale.value));
|
||||
|
||||
const setLocale = (value) => {
|
||||
router.push(localePath(router.currentRoute.value.path, value));
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const title = computed(() => {
|
||||
return t(route.name);
|
||||
});
|
||||
|
||||
const toggleTheme = () => {
|
||||
const themeValue = theme.global.current.value.dark ? 'light' : 'dark';
|
||||
theme.global.name.value = themeValue;
|
||||
cookieTheme.value = themeValue;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
toggleTheme();
|
||||
await nextTick();
|
||||
toggleTheme();
|
||||
});
|
||||
|
||||
return {
|
||||
theme,
|
||||
t,
|
||||
locale,
|
||||
localesList,
|
||||
setLocale,
|
||||
localeData,
|
||||
title,
|
||||
toggleTheme,
|
||||
};
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
notifications: [
|
||||
'Mike, John responded to your email',
|
||||
'You have 5 new tasks',
|
||||
"You're now a friend with Andrew",
|
||||
'Another Notification',
|
||||
'Another One',
|
||||
],
|
||||
}),
|
||||
|
||||
computed: {
|
||||
responsive() {
|
||||
const display = useDisplay();
|
||||
return display.lgAndUp.value;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.setDrawer(this.responsive);
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(useAppStore, ['setDrawer', 'toggleDrawer']),
|
||||
onClick() {
|
||||
this.setDrawer(!useAppStore().drawer);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Fix coming in v2.0.8 */
|
||||
#core-app-bar {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#core-app-bar a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
94
frontend/components/core/Drawer.vue
Normal file
94
frontend/components/core/Drawer.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<v-navigation-drawer id="app-drawer" v-model="inputValue" width="260" elevation="5" floating rail>
|
||||
<v-row justify="center" class="text-center">
|
||||
<v-col class="pt-8">
|
||||
<v-avatar color="white">
|
||||
<v-img src="/icon.png" height="34" contain />
|
||||
</v-avatar>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="mx-3 mb-3" />
|
||||
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item v-for="(link, i) in links" :key="i" :to="link.to" active-class="primary white--text">
|
||||
<template #prepend>
|
||||
<v-icon>{{ link.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ link.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<template #append>
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item tag="a" href="https://github.com/rekryt/iplist" target="_blank">
|
||||
<template #prepend>
|
||||
<v-icon>mdi-github</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-light">GitHub</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
<i18n lang="json">
|
||||
{
|
||||
"en": {
|
||||
"main": "Portals",
|
||||
"groups": "Groups",
|
||||
"about": "About"
|
||||
},
|
||||
"ru": {
|
||||
"main": "Порталы",
|
||||
"groups": "Группы",
|
||||
"about": "О проекте"
|
||||
},
|
||||
"cn": {
|
||||
"main": "通过门户",
|
||||
"groups": "分组",
|
||||
"about": "关于项目"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
<script>
|
||||
import { mapActions, mapState } from 'pinia';
|
||||
import { useAppStore } from '~/stores/app';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
});
|
||||
const localePath = useLocalePath();
|
||||
const links = computed(() => [
|
||||
{
|
||||
to: localePath('/'),
|
||||
icon: 'mdi-web-sync',
|
||||
text: t('main'),
|
||||
},
|
||||
{
|
||||
to: localePath('/about'),
|
||||
icon: 'mdi-information-outline',
|
||||
text: t('about'),
|
||||
},
|
||||
]);
|
||||
|
||||
return { t, links };
|
||||
},
|
||||
computed: {
|
||||
...mapState(useAppStore, ['color']),
|
||||
inputValue: {
|
||||
get() {
|
||||
return useAppStore().drawer;
|
||||
},
|
||||
set(val) {
|
||||
this.setDrawer(val);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions(useAppStore, ['setDrawer', 'toggleDrawer']),
|
||||
},
|
||||
};
|
||||
</script>
|
56
frontend/components/core/Footer.vue
Normal file
56
frontend/components/core/Footer.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n({
|
||||
useScope: 'local',
|
||||
});
|
||||
const links = computed(() => [{ name: t('issue'), Link: 'https://github.com/rekryt/iplist/issues' }]);
|
||||
</script>
|
||||
<template>
|
||||
<v-footer id="core-footer">
|
||||
<div class="footer-items">
|
||||
<a v-for="link in links" :key="link.name" :href="link.Link" class="footer-link">
|
||||
{{ link.name }}
|
||||
</a>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<span class="font-weight-light copyright">
|
||||
© {{ new Date().getFullYear() }}
|
||||
<a href="https://vk.com/rekryt" target="_blank">Rekryt</a>
|
||||
<v-icon style="margin-top: -3px" color="tertiary" size="17">mdi-star</v-icon>
|
||||
for a better web
|
||||
<br />
|
||||
</span>
|
||||
</v-footer>
|
||||
</template>
|
||||
<i18n lang="json">
|
||||
{
|
||||
"en": {
|
||||
"issue": "Issue"
|
||||
},
|
||||
"ru": {
|
||||
"issue": "Задать вопрос"
|
||||
},
|
||||
"cn": {
|
||||
"issue": "提交问题"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
|
||||
<style lang="scss">
|
||||
#core-footer {
|
||||
flex: 0 0 auto;
|
||||
z-index: 0;
|
||||
margin-top: auto;
|
||||
height: 100px;
|
||||
}
|
||||
.copyright {
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer-link {
|
||||
margin: 5px 5px 5px 0;
|
||||
}
|
||||
</style>
|
25
frontend/components/core/View.vue
Normal file
25
frontend/components/core/View.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<v-main class="grey lighten-3">
|
||||
<v-fade-transition mode="out-in">
|
||||
<div id="core-view">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CoreView',
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
body {
|
||||
min-width: 320px;
|
||||
}
|
||||
#core-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
93
frontend/components/material/Card.vue
Normal file
93
frontend/components/material/Card.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<v-card class="v-card--material-card" :style="styles" v-bind="$attrs">
|
||||
<helper-offset v-if="hasOffset" :inline="inline" :full-width="fullWidth" :offset="offset">
|
||||
<v-card
|
||||
v-if="!$slots.offset"
|
||||
:color="color"
|
||||
:elevation="elevation"
|
||||
class="v-card--material__header d-flex align-center"
|
||||
min-height="80"
|
||||
>
|
||||
<slot v-if="!title && !text" name="header" />
|
||||
<div v-else class="px-3">
|
||||
<h4 class="title font-weight-light mb-2" v-text="title" />
|
||||
<p class="category font-weight-thin mb-0" v-text="text" />
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<slot v-else name="offset" />
|
||||
</helper-offset>
|
||||
|
||||
<v-card-text class="cardText">
|
||||
<slot />
|
||||
</v-card-text>
|
||||
|
||||
<v-divider v-if="$slots.actions" class="mx-3" />
|
||||
|
||||
<v-card-actions v-if="$slots.actions">
|
||||
<slot name="actions" />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MaterialCard',
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
color: {
|
||||
type: String,
|
||||
default: 'secondary',
|
||||
},
|
||||
elevation: {
|
||||
type: [Number, String],
|
||||
default: 10,
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
offset: {
|
||||
type: [Number, String],
|
||||
default: 24,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasOffset() {
|
||||
return this.$slots.header || this.$slots.offset || this.title || this.text;
|
||||
},
|
||||
styles() {
|
||||
if (!this.hasOffset) return null;
|
||||
|
||||
return {
|
||||
marginBottom: `${this.offset}px`,
|
||||
marginTop: `${this.offset * 2}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.v-card--material-card {
|
||||
overflow: visible;
|
||||
}
|
||||
.cardText {
|
||||
font-size: 18px !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user