feat: new frontend

This commit is contained in:
Rekryt
2025-07-02 20:40:13 +03:00
parent bda887db3c
commit 7d7c82514f
111 changed files with 5223 additions and 52 deletions

6
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
.nuxt
.output
node_modules
tmp
package-lock.json

43
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,43 @@
[*]
charset=utf-8
end_of_line=lf
insert_final_newline=false
indent_style=space
tab_width=4
[*.vue]
indent_style=space
indent_size=4
[*.scss]
indent_style=space
indent_size=4
[*.json]
indent_style=space
indent_size=4
[{phpunit.xml.dist,*.jhm,*.xslt,*.xul,*.rng,*.xsl,*.xsd,*.ant,*.tld,*.fxml,*.jrxml,*.xml,*.jnlp,*.wsdl}]
indent_style=space
indent_size=4
[*.svg]
indent_style=space
indent_size=4
[.editorconfig]
indent_style=space
indent_size=4
[{*.gy,*.groovy,*.gant,*.gdsl}]
indent_style=space
indent_size=4
[{*.ats,*.ts}]
indent_style=space
indent_size=4
[{*.yml,*.yaml}]
indent_style=space
indent_size=2

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
API_BASE_URL="https://iplist.opencck.org/"

24
frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,24 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:nuxt/recommended',
'plugin:vue/vue3-recommended',
'prettier',
],
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
rules: {
'vue/html-indent': 'off',
'vue/html-self-closing': 'off',
'vue/multi-word-component-names': 'off',
},
};

133
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,133 @@
# Dependencies
node_modules
.output
.nitro
jspm_packages
vendor
# Only keep yarn.lock in the root
package-lock.json
*/**/yarn.lock
composer.lock
# Logs
/logs
*.log*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Cache
.phpunit.result.cache
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Packages
packages/*/LICENSE
# VSCode
.vscode
# Intellij idea
*.iml
.idea
# Zend \ Eclipse
/.settings/
/.buildpath
/.project
# OSX
.DS_Store
.AppleDouble
.LSOverride
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
CHANGELOG.md
TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# Vim swap files
*.swp
# Robots Files
robots.txt
# Ignore Service Worker
sw.*

14
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,14 @@
{
"useTabs": false,
"printWidth": 120,
"tabWidth": 4,
"trailingComma": "es5",
"jsxBracketSameLine": false,
"bracketSpacing": true,
"semi": true,
"singleQuote": true,
"htmlWhitespaceSensitivity": "ignore",
"phpVersion": "7.1",
"braceStyle": "1tbs",
"endOfLine": "auto"
}

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Nuxt + Vuetify frontend for IPList
## Develop
```shell
npm install
npm run dev
```
## Build
```shell
npm run generate
```

View File

@@ -0,0 +1,3 @@
body {
min-width: 320px;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">
&copy; {{ 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>

View 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>

View 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>

View File

@@ -0,0 +1,6 @@
export function useCookieTheme() {
const theme = useCookie<string>('theme');
if (!theme.value) theme.value = 'dark';
return theme;
}

View File

@@ -0,0 +1,10 @@
<template>
<v-app>
<core-drawer />
<core-app-bar />
<core-view>
<slot></slot>
<core-footer />
</core-view>
</v-app>
</template>

101
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,101 @@
import eslintPlugin from 'vite-plugin-eslint';
const path = require('path');
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
typescript: {
strict: true,
},
app: {
head: {
title: 'IP Address Collection and Management Service',
meta: [
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
charset: 'utf-8',
},
],
link: [
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons',
},
],
},
},
css: ['vuetify/lib/styles/main.sass', '@mdi/font/css/materialdesignicons.min.css', 'assets/scss/default.scss'],
modules: [
[
'@pinia/nuxt',
{
autoImports: [
// automatically imports `defineStore`
'defineStore', // import { defineStore } from 'pinia'
// automatically imports `defineStore` as `definePiniaStore`
['defineStore', 'definePiniaStore'], // import { defineStore as definePiniaStore } from 'pinia'
],
},
],
'@kevinmarrec/nuxt-pwa',
'@nuxtjs/i18n',
],
pwa: {
meta: {
name: 'IPList',
title: 'IPList',
author: 'Rekryt',
description: 'IP Address Collection and Management Service with multiple formats',
theme_color: '#000000',
},
},
i18n: {
strategy: 'prefix_except_default',
locales: [
{ code: 'en', language: 'en-US' },
{ code: 'ru', language: 'ru-RU' },
{ code: 'cn', language: 'zh-CN' },
],
defaultLocale: 'en',
},
build: {
transpile: ['vuetify'],
},
vite: {
plugins: [eslintPlugin()],
define: {
'process.env.DEBUG': false,
},
server: {
/**
* If develop from docker
watch: {
usePolling: true,
},
*/
},
},
nitro: {
devProxy: {
'/api': {
target: process.env.API_BASE_URL ?? 'https://iplist.opencck.org/',
hostRewrite: process.env.API_BASE_URL ?? 'https://iplist.opencck.org/',
changeOrigin: true,
},
},
output: {
publicDir: path.join(__dirname, '../public/'),
},
},
compatibilityDate: '2025-06-28',
});

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "iplist",
"version": "1.0.0",
"author": "Rekryt <rekrytkw@gmail.com>",
"license": "MIT",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"start": "node .output/server/index.mjs",
"test": "vitest --run --reporter verbose --globals",
"lint": "eslint --ext .ts,.js,.vue ."
},
"devDependencies": {
"@kevinmarrec/nuxt-pwa": "^0.17.0",
"@nuxtjs/i18n": "^9.5.6",
"@typescript-eslint/eslint-plugin": "^8.35.0",
"@typescript-eslint/parser": "8.35.0",
"autoprefixer": "^10.4.21",
"consola": "^3.4.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-nuxt": "^4.0.0",
"eslint-plugin-vitest": "^0.5.4",
"eslint-plugin-vue": "^9.33.0",
"nuxt": "3.17.5",
"prettier": "^3.6.2",
"sass": "^1.89.2",
"vite-plugin-eslint": "^1.8.1"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@pinia/nuxt": "0.11.1",
"css-select": "5.1.0",
"fs-extra": "^11.3.0",
"pinia": "3.0.3",
"vue-github-button": "^3.1.3",
"vuetify": "3.8.11"
},
"overrides": {
"vue": "latest"
}
}

34
frontend/pages/about.vue Normal file
View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
const { t } = useI18n({
useScope: 'local',
});
</script>
<i18n lang="json">
{
"en": {
"title": "IP Address Collection and Management Service",
"about": "This service is designed to collect and update IP addresses (IPv4 and IPv6), as well as their CIDR zones for specified domains. It is an asynchronous PHP web server based on AMPHP and uses the Linux utilities whois and ipcalc. The service provides interfaces to retrieve lists of IP address zones of specified domains (IPv4 addresses, IPv6 addresses, as well as CIDRv4 and CIDRv6 zones) in various formats, including plain text, JSON, and script formats for importing into \"Address List\" on routers such as MikroTik (RouterOS), Keenetic KVAS\\BAT, SwitchyOmega, Amnezia, and others."
},
"ru": {
"title": "Сервис сбора IP-адресов и CIDR зон",
"about": "Данный сервис предназначен для сбора и обновления IP-адресов (IPv4 и IPv6), а также их CIDR зон для указанных доменов. Это асинхронный PHP веб-сервер на основе AMPHP и Linux-утилит whois и ipcalc. Сервис предоставляет интерфейсы для получения списков зон ip адресов указанных доменов (IPv4 адресов, IPv6 адресов, а также CIDRv4 и CIDRv6 зон) в различных форматах, включая текстовый, JSON, форматы скриптов для добавления в \"Address List\" на роутерах Mikrotik (RouterOS), Keenetic KVAS\\BAT, SwitchyOmega, Amnezia и др."
},
"cn": {
"title": "IP地址收集与管理服务",
"about": "该服务用于收集和更新指定域名的 IP 地址IPv4 和 IPv6及其 CIDR 区段。它是一个基于 AMPHP 的异步 PHP Web 服务器,使用 Linux 工具 whois 和 ipcalc。该服务提供接口以多种格式包括纯文本、JSON以及可用于 MikroTikRouterOS、Keenetic KVAS\\BAT、SwitchyOmega、Amnezia 等路由器的“地址列表”导入脚本)获取指定域名的 IP 地址区段IPv4 地址、IPv6 地址、CIDRv4 和 CIDRv6 区段)列表。"
}
}
</i18n>
<template>
<v-container fluid>
<div class="max-w-[960px] mx-auto">
<h1 class="mb-4">{{ t('title') }}</h1>
<material-card>{{ t('about') }}</material-card>
</div>
</v-container>
</template>
<style lang="scss">
.max-w-\[960px\] {
max-width: 960px;
}
</style>

6
frontend/pages/index.vue Normal file
View File

@@ -0,0 +1,6 @@
<script lang="ts" setup></script>
<template>
<v-container class="my-auto" fluid>
<base-form></base-form>
</v-container>
</template>

View File

@@ -0,0 +1,65 @@
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
export default defineNuxtPlugin((nuxtApp) => {
const cookieTheme = useCookieTheme();
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: cookieTheme.value && cookieTheme.value !== 'system' ? cookieTheme.value : 'light',
themes: {
light: {
dark: false,
colors: {
primary: '#4caf50',
secondary: '#4caf50',
background: '#FFFFFF',
surface: '#FFFFFF',
'primary-darken-1': '#3700B3',
'secondary-darken-1': '#018786',
error: '#f55a4e',
info: '#00d3ee',
success: '#5cb860',
warning: '#ffa21a',
},
},
// dark: {
// dark: true,
// colors: {
// background: '#121212',
// error: '#CF6679',
// info: '#2196F3',
// 'on-background': '#fff',
// 'on-error': '#fff',
// 'on-info': '#fff',
// 'on-primary': '#fff',
// 'on-primary-darken-1': '#fff',
// 'on-secondary': '#fff',
// 'on-secondary-darken-1': '#fff',
// 'on-success': '#fff',
// 'on-surface': '#fff',
// 'on-surface-bright': '#000',
// 'on-surface-light': '#fff',
// 'on-surface-variant': '#000000',
// 'on-warning': '#fff',
// primary: '#2196F3',
// 'primary-darken-1': '#277CC1',
// secondary: '#54B6B2',
// 'secondary-darken-1': '#48A9A6',
// success: '#4CAF50',
// surface: '#212121',
// 'surface-bright': '#ccbfd6',
// 'surface-light': '#424242',
// 'surface-variant': '#c8c8c8',
// warning: '#FB8C00',
// },
// },
},
},
});
nuxtApp.vueApp.use(vuetify);
});

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
frontend/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -0,0 +1,81 @@
#!/bin/sh
SERVICE_URL="https://iplist.opencck.org"
NAME="homeproxy"
RESOURCES_DIR="/etc/$NAME/resources"
mkdir -p "$RESOURCES_DIR"
RUN_DIR="/var/run/$NAME"
LOG_PATH="$RUN_DIR/$NAME.log"
mkdir -p "$RUN_DIR"
log() {
echo -e "$(date "+%Y-%m-%d %H:%M:%S") $*" >> "$LOG_PATH"
}
set_lock() {
local act="$1"
local type="$2"
local lock="$RUN_DIR/update_resources-$type.lock"
if [ "$act" = "set" ]; then
if [ -e "$lock" ]; then
log "[$(to_upper "$type")] A task is already running."
exit 2
else
touch "$lock"
fi
elif [ "$act" = "remove" ]; then
rm -f "$lock"
fi
}
to_upper() {
echo -e "$1" | tr "[a-z]" "[A-Z]"
}
check_list_update() {
local listtype="$1"
local listrepo="$2"
local listref="$3"
local listname="$4"
local wget="wget --timeout=10 -q"
set_lock "set" "$listtype"
$wget "$listrepo/?format=text&data=$listref" -O "$RUN_DIR/$listname"
if [ ! -s "$RUN_DIR/$listname" ]; then
rm -f "$RUN_DIR/$listname"
log "[$listrepo/?format=text&data=$listref] Update failed."
set_lock "remove" "$listtype"
return 1
fi
mv -f "$RUN_DIR/$listname" "$RESOURCES_DIR/$listtype.${listname##*.}"
echo -e "$(date +%F\ %H:%M:%S)" > "$RESOURCES_DIR/$listtype.ver"
log "[$listrepo/?format=text&data=$listref] Successfully updated."
set_lock "remove" "$listtype"
return 0
}
case "$1" in
"china_ip4")
check_list_update "$1" "$SERVICE_URL" "cidr4" "ipv4.txt"
;;
"china_ip6")
check_list_update "$1" "$SERVICE_URL" "cidr6" "ipv6.txt"
;;
"gfw_list")
check_list_update "$1" "$SERVICE_URL" "domains" "gfw.txt"
;;
"china_list")
check_list_update "$1" "$SERVICE_URL" "domains" "direct-list.txt"
;;
*)
echo -e "Usage: $0 <china_ip4 / china_ip6 / gfw_list / china_list>"
exit 1
;;
esac

17
frontend/stores/app.ts Normal file
View File

@@ -0,0 +1,17 @@
export const useAppStore = defineStore('app', {
state: () => ({
drawer: false,
color: 'success',
}),
actions: {
setDrawer(value: boolean) {
this.drawer = value;
},
setColor(value: string) {
this.color = value;
},
toggleDrawer() {
this.drawer = !this.drawer;
},
},
});

10
frontend/stores/main.ts Normal file
View File

@@ -0,0 +1,10 @@
export const useMainStore = defineStore('main', {
state: () => ({
sidebar: false,
}),
actions: {
toggleSidebar() {
this.sidebar = !this.sidebar;
},
},
});

12
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
// https://v3.nuxtjs.org/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"strict": true,
"rootDir": "./",
"moduleResolution": "Node",
"paths": {
"~/*": ["./*"]
}
}
}