Support localized web UI (#912)

* Add support for localization
* Add docs & format code
* Add lib to docs
This commit is contained in:
qwerty287 2022-05-16 21:18:48 +02:00 committed by GitHub
parent 687d57217d
commit 7d7d75d7e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 660 additions and 192 deletions

View file

@ -0,0 +1,17 @@
# Translations
Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library, thus you can easily translate the web UI into your language. Therefore, copy the file `web/src/assets/locales/en.json` to the same path with your language's code and `.json` as name.
Then, translate content of this file, but only the values:
```json
{
"dont_translate": "Only translate this text"
}
```
To add support for time formatting, import the language into two files:
1. `web/src/compositions/useDate.ts`: Just add a line like `import 'dayjs/locale/en';` to the first block of `import` statements and replace `en` with your language's code.
2. `web/src/utils/timeAgo.ts`: Add a line like `import en from 'javascript-time-ago/locale/en.json';` to the other `import`-statements and replace both `en`s with your language's code. Then, add the line `TimeAgo.addDefaultLocale(en);` to the other lines of them, and replace `en` with your language's code.
Then, the web UI should be available in your language. You should open a pull request to our repository to get your changes into the next release.

View file

@ -4,3 +4,4 @@ coverage/
package.json
tsconfig.eslint.json
tsconfig.json
src/assets/locales/

View file

@ -17,6 +17,7 @@
"test": "echo 'No tests configured' && exit 0"
},
"dependencies": {
"@intlify/vite-plugin-vue-i18n": "^3.4.0",
"@kyvg/vue3-notification": "2.3.4",
"@meforma/vue-toaster": "1.2.2",
"ansi-to-html": "0.7.2",
@ -29,6 +30,7 @@
"node-emoji": "1.11.0",
"pinia": "2.0.0",
"vue": "v3.2.20",
"vue-i18n": "9",
"vue-router": "4.0.10"
},
"devDependencies": {

View file

@ -0,0 +1,208 @@
{
"login": "Login",
"welcome": "Welcome to Woodpecker",
"repos": "Repos",
"repositories": "Repositories",
"logout": "Logout",
"search": "Search...",
"username": "Username",
"password": "Password",
"url": "URL",
"not_found": {
"not_found": "Whoa 404, either we broke something or you had a typing mishap :-/",
"back_home": "Back to home"
},
"time": {
"tmpl": "MMM D, YYYY, HH:mm z",
"weeks_short": "w",
"days_short": "d",
"hours_short": "h",
"min_short": "min",
"sec_short": "sec",
"not_started": "not started yet"
},
"repo": {
"activity": "Activity",
"branches": "Branches",
"add": "Add repository",
"user_none": "This organization / user does not have any projects yet.",
"not_allowed": "Not allowed to access this repository",
"enable": {
"reload": "Reload repositories",
"enable": "Enable",
"enabled": "Already enabled",
"success": "Repository enabled",
"list_reloaded": "Repository list reloaded"
},
"settings": {
"settings": "Settings",
"general": {
"general": "General",
"project": "Project settings",
"save": "Save settings",
"success": "Repository settings updated",
"pipeline_path": {
"path": "Pipeline path",
"default": "By default: .woodpecker/*.yml -> .woodpecker.yml -> .drone.yml",
"desc": "Path to your pipeline config (for example<span class=\"bg-gray-300 dark:bg-dark-100 rounded-md px-1\">my/path/</span>). Folders should end with a<span class=\"bg-gray-300 dark:bg-dark-100 rounded-md px-1\">/</span>."
},
"allow_pr": {
"allow": "Allow Pull Requests",
"desc": "Pipelines can run on pull requests."
},
"protected": {
"protected": "Protected",
"desc": "Every pipeline needs to be approved before being executed."
},
"trusted": {
"trusted": "Trusted",
"desc": "Underlying pipeline containers get access to escalated capabilities like mounting volumes."
},
"visibility": {
"visibility": "Project visibility",
"public": {
"public": "Public",
"desc": "Every user can see your project without being logged in."
},
"private": {
"private": "Private",
"desc": "Only authenticated users of the Woodpecker instance can see this project."
},
"internal": {
"internal": "Internal",
"desc": "Only you and other owners of the repository can see this project."
}
},
"timeout": {
"timeout": "Timeout",
"minutes": "minutes"
},
"cancel_prev": {
"cancel": "Cancel previous pipelines",
"desc": "Enable to cancel running pipelines of the same event and context before starting the newly triggered one."
}
},
"secrets": {
"secrets": "Secrets",
"desc": "Secrets can be passed to individual pipeline steps at runtime as environmental variables.",
"none": "There are no secrets yet.",
"add": "Add secret",
"save": "Save secret",
"show": "Show secrets",
"name": "Name",
"value": "Value",
"deleted": "Secret deleted",
"created": "Secret created",
"saved": "Secret saved",
"images": {
"images": "Available for following images",
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
},
"events": {
"events": "Available at following events",
"pr_warning": "Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets."
}
},
"registries": {
"registries": "Registries",
"creds": "Registry credentials",
"desc": "Registries credentials can be added to use private images for your pipeline.",
"show": "Show registries",
"add": "Add registry",
"none": "There are no registry credentials yet.",
"save": "Save registry",
"created": "Registry credentials created",
"saved": "Registry credentials saved",
"deleted": "Registry credentials deleted",
"address": {
"address": "Address",
"placeholder": "Registry Address (e.g. docker.io)"
}
},
"badge": {
"badge": "Badge",
"url_branch": "URL for specific branch",
"markdown": "Markdown"
},
"actions": {
"actions": "Actions",
"repair": {
"repair": "Repair repository",
"success": "Repository repaired"
},
"disable": {
"disable": "Disable repository",
"success": "Repository disabled"
},
"delete": {
"delete": "Delete repository",
"confirm": "All data will be lost after this action!!!\n\nDo you really want to proceed?",
"success": "Repository deleted"
}
}
},
"build": {
"created": "Created",
"tasks": "Tasks",
"config": "Config",
"files": "Changed files ({0})",
"no_files": "No files have been changed.",
"execution_error": "Execution error",
"no_pipelines": "No pipelines have been started yet.",
"step_not_started": "This step hasn't started yet.",
"pipelines_for": "Pipelines for branch \"{0}\"",
"actions": {
"cancel": "Cancel",
"restart": "Restart",
"canceled": "This step has been canceled.",
"cancel_success": "Pipeline canceled",
"restart_success": "Pipeline restarted"
},
"protected": {
"awaits": "This pipeline is awaiting approval by some maintainer!",
"approve": "Approve",
"decline": "Decline",
"declined": "This pipeline has been declined!"
},
"event": {
"push": "Push",
"tag": "Tag",
"pr": "Pull Request",
"deploy": "Deploy"
}
}
},
"user": {
"oauth_error": "Error while authenticating against OAuth provider",
"internal_error": "Some internal error occurred",
"access_denied": "You are not allowed to login",
"token": "Your Personal Token",
"shell_setup": "Shell setup",
"api_usage": "Example API Usage",
"cli_usage": "Example CLI Usage",
"dl_cli": "Download CLI",
"shell_setup_before": "do shell setup steps before"
}
}

View file

@ -9,7 +9,9 @@
<Icon name="since" />
<Tooltip>
<span>{{ since }}</span>
<template #popper><span class="font-bold">Created</span> {{ created }}</template>
<template #popper
><span class="font-bold">{{ $t('created') }}</span> {{ created }}</template
>
</Tooltip>
</div>
<div class="flex space-x-2 items-center">

View file

@ -21,7 +21,7 @@
<BuildFeedItem :build="build" />
</router-link>
<span v-if="sortedBuildFeed.length === 0" class="text-gray-500 m-4">No pipelines have been started yet.</span>
<span v-if="sortedBuildFeed.length === 0" class="text-gray-500 m-4">{{ $t('repo.build.no_pipelines') }}</span>
</div>
</template>

View file

@ -10,8 +10,8 @@
:to="{ name: 'repos' }"
class="mx-4 hover:bg-lime-700 dark:hover:bg-gray-600 px-4 py-1 rounded-md"
>
<span class="flex md:hidden">Repos</span>
<span class="hidden md:flex">Repositories</span>
<span class="flex md:hidden">{{ $t('repos') }}</span>
<span class="hidden md:flex">{{ $t('repositories') }}</span>
</router-link>
</div>
<div class="flex ml-auto items-center space-x-4 text-white dark:text-gray-500">
@ -29,7 +29,7 @@
<router-link v-if="user" :to="{ name: 'user' }">
<img v-if="user && user.avatar_url" class="w-8" :src="`${user.avatar_url}`" />
</router-link>
<Button v-else text="Login" @click="doLogin" />
<Button v-else :text="$t('login')" @click="doLogin" />
<ActiveBuilds v-if="user" />
</div>
</div>

View file

@ -9,7 +9,7 @@
<BuildItem :build="build" />
</router-link>
<Panel v-if="builds.length === 0">
<span class="text-gray-500">No pipelines have been started yet.</span>
<span class="text-gray-500">{{ $t('repo.build.no_pipelines') }}</span>
</Panel>
</div>
</template>

View file

@ -22,10 +22,10 @@
<div class="text-gray-300 mx-auto">
<span v-if="proc?.error" class="text-red-500">{{ proc.error }}</span>
<span v-else-if="proc?.state === 'skipped'" class="text-orange-300 dark:text-orange-800"
>This step has been canceled.</span
<span v-else-if="proc?.state === 'skipped'" class="text-orange-300 dark:text-orange-800">
>{{ $t('repo.build.actions.canceled') }}</span
>
<span v-else-if="!proc?.start_time" class="dark:text-gray-500">This step hasn't started yet.</span>
<span v-else-if="!proc?.start_time" class="dark:text-gray-500">{{ $t('repo.build.step_not_started') }}</span>
</div>
</div>
</template>

View file

@ -1,7 +1,7 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<h1 class="text-xl ml-2 text-gray-500">Actions</h1>
<h1 class="text-xl ml-2 text-gray-500">{{ $t('repo.settings.actions.actions') }}</h1>
</div>
<div class="flex flex-col">
@ -9,8 +9,8 @@
class="mr-auto mt-4"
color="blue"
start-icon="heal"
text="Repair repository"
:is-loading="isRepairingRepo"
:text="$t('repo.settings.actions.repair.repair')"
@click="repairRepo"
/>
@ -18,8 +18,8 @@
class="mr-auto mt-4"
color="blue"
start-icon="turn-off"
text="Disable repository"
:is-loading="isDeactivatingRepo"
:text="$t('repo.settings.actions.disable.disable')"
@click="deactivateRepo"
/>
@ -27,8 +27,8 @@
class="mr-auto mt-4"
color="red"
start-icon="trash"
text="Delete repository"
:is-loading="isDeletingRepo"
:text="$t('repo.settings.actions.delete.delete')"
@click="deleteRepo"
/>
</div>
@ -37,6 +37,7 @@
<script lang="ts">
import { defineComponent, inject, Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue';
@ -55,6 +56,7 @@ export default defineComponent({
const apiClient = useApiClient();
const router = useRouter();
const notifications = useNotifications();
const i18n = useI18n();
const repo = inject<Ref<Repo>>('repo');
@ -64,7 +66,7 @@ export default defineComponent({
}
await apiClient.repairRepo(repo.value.owner, repo.value.name);
notifications.notify({ title: 'Repository repaired', type: 'success' });
notifications.notify({ title: i18n.t('repo.settings.actions.repair.success'), type: 'success' });
});
const { doSubmit: deleteRepo, isLoading: isDeletingRepo } = useAsyncAction(async () => {
@ -74,12 +76,12 @@ export default defineComponent({
// TODO use proper dialog
// eslint-disable-next-line no-alert, no-restricted-globals
if (!confirm('All data will be lost after this action!!!\n\nDo you really want to proceed?')) {
if (!confirm(i18n.t('repo.settings.actions.delete.confirm'))) {
return;
}
await apiClient.deleteRepo(repo.value.owner, repo.value.name);
notifications.notify({ title: 'Repository deleted', type: 'success' });
notifications.notify({ title: i18n.t('repo.settings.actions.delete.success'), type: 'success' });
await router.replace({ name: 'repos' });
});
@ -89,7 +91,7 @@ export default defineComponent({
}
await apiClient.deleteRepo(repo.value.owner, repo.value.name, false);
notifications.notify({ title: 'Repository disabled', type: 'success' });
notifications.notify({ title: i18n.t('repo.settings.actions.disable.success'), type: 'success' });
await router.replace({ name: 'repos' });
});

View file

@ -1,7 +1,7 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<h1 class="text-xl ml-2 text-gray-500">Badge</h1>
<h1 class="text-xl ml-2 text-gray-500">{{ $t('repo.settings.badge.badge') }}</h1>
<a v-if="badgeUrl" :href="badgeUrl" target="_blank" class="ml-auto">
<img :src="badgeUrl" />
</a>
@ -9,17 +9,17 @@
<div class="flex flex-col space-y-4">
<div>
<h2 class="text-lg text-gray-500 ml-2">Url</h2>
<h2 class="text-lg text-gray-500 ml-2">{{ $t('url') }}</h2>
<pre class="box">{{ baseUrl }}{{ badgeUrl }}</pre>
</div>
<div>
<h2 class="text-lg text-gray-500 ml-2">Url for specific branch</h2>
<h2 class="text-lg text-gray-500 ml-2">{{ $t('repo.settings.badge.url_branch') }}</h2>
<pre class="box">{{ baseUrl }}{{ badgeUrl }}?branch=<span class="font-bold">&lt;branch&gt;</span></pre>
</div>
<div>
<h2 class="text-lg text-gray-500 ml-2">Markdown</h2>
<h2 class="text-lg text-gray-500 ml-2">{{ $t('repo.settings.badge.markdown') }}</h2>
<pre class="box">[![status-badge]({{ baseUrl }}{{ badgeUrl }})]({{ baseUrl }}{{ repoUrl }})</pre>
</div>
</div>

View file

@ -1,74 +1,90 @@
<template>
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<h1 class="text-xl ml-2 text-gray-500">General</h1>
<h1 class="text-xl ml-2 text-gray-500">{{ $t('general') }}</h1>
</div>
<div v-if="repoSettings" class="flex flex-col">
<InputField label="Pipeline path" docs-url="docs/usage/project-settings#pipeline-path">
<InputField
docs-url="docs/usage/project-settings#pipeline-path"
:label="$t('repo.settings.general.pipeline_path.path')"
>
<TextField
v-model="repoSettings.config_file"
class="max-w-124"
placeholder="By default: .woodpecker/*.yml -> .woodpecker.yml -> .drone.yml"
:placeholder="$t('repo.settings.general.pipeline_path.default')"
/>
<template #description>
<p class="text-sm text-gray-400 dark:text-gray-600">
Path to your pipeline config (for example
<span class="bg-gray-300 dark:bg-dark-100 rounded-md px-1">my/path/</span>). Folders should end with a
<span class="bg-gray-300 dark:bg-dark-100 rounded-md px-1">/</span>.
</p>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="text-sm text-gray-400 dark:text-gray-600" v-html="$t('repo.settings.general.pipeline_path.desc')" />
</template>
</InputField>
<InputField label="Project settings" docs-url="docs/usage/project-settings#project-settings-1">
<InputField
docs-url="docs/usage/project-settings#project-settings-1"
:label="$t('repo.settings.general.project')"
>
<Checkbox
v-model="repoSettings.allow_pr"
label="Allow Pull Request"
description="Pipelines can run on pull requests."
:label="$t('repo.settings.general.allow_pr.allow')"
@description="$t('repo.settings.general.allow_pr.desc')"
/>
<Checkbox
v-model="repoSettings.gated"
label="Protected"
description="Every pipeline needs to be approved before being executed."
:label="$t('repo.settings.general.protected.protected')"
@description="$t('repo.settings.general.protected.desc')"
/>
<Checkbox
v-if="user?.admin"
v-model="repoSettings.trusted"
label="Trusted"
description="Underlying pipeline containers get access to escalated capabilities like mounting volumes."
:label="$t('repo.settings.general.trusted.trusted')"
:description="$t('repo.settings.general.trusted.desc')"
/>
</InputField>
<InputField label="Project visibility" docs-url="docs/usage/project-settings#project-visibility">
<InputField
docs-url="docs/usage/project-settings#project-visibility"
:label="$t('repo.settings.general.visibility.visibility')"
>
<RadioField v-model="repoSettings.visibility" :options="projectVisibilityOptions" />
</InputField>
<InputField label="Timeout" docs-url="docs/usage/project-settings#timeout">
<InputField docs-url="docs/usage/project-settings#timeout" :label="$t('repo.settings.general.timeout.timeout')">
<div class="flex items-center">
<NumberField v-model="repoSettings.timeout" class="w-24" />
<span class="ml-4 text-gray-600">minutes</span>
<span class="ml-4 text-gray-600">{{ $t('repo.settings.general.timeout.minutes') }}</span>
</div>
</InputField>
<InputField label="Cancel previous pipelines" docs-url="docs/usage/project-settings#cancel-previous-pipelines">
<InputField
docs-url="docs/usage/project-settings#cancel-previous-pipelines"
:label="$t('repo.settings.general.cancel_prev.cancel')"
>
<CheckboxesField
v-model="repoSettings.cancel_previous_pipeline_events"
:options="cancelPreviousBuildEventsOptions"
/>
<template #description>
<p class="text-sm text-gray-400 dark:text-gray-600">
Enable to cancel running pipelines of the same event and context before starting the newly triggered one.
{{ $t('repo.settings.general.cancel_prev.desc') }}
</p>
</template>
</InputField>
<Button class="mr-auto" color="green" text="Save settings" :is-loading="isSaving" @click="saveRepoSettings" />
<Button
class="mr-auto"
color="green"
:is-loading="isSaving"
:text="$t('repo.settings.general.save')"
@click="saveRepoSettings"
/>
</div>
</Panel>
</template>
<script lang="ts">
import { defineComponent, inject, onMounted, Ref, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
import Checkbox from '~/components/form/Checkbox.vue';
@ -86,34 +102,6 @@ import useNotifications from '~/compositions/useNotifications';
import { Repo, RepoSettings, RepoVisibility, WebhookEvents } from '~/lib/api/types';
import RepoStore from '~/store/repos';
const projectVisibilityOptions: RadioOption[] = [
{
value: RepoVisibility.Public,
text: 'Public',
description: 'Every user can see your project without being logged in.',
},
{
value: RepoVisibility.Private,
text: 'Private',
description: 'Only authenticated users of the Woodpecker instance can see this project.',
},
{
value: RepoVisibility.Internal,
text: 'Internal',
description: 'Only you and other owners of the repository can see this project.',
},
];
const cancelPreviousBuildEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: 'Push' },
{ value: WebhookEvents.Tag, text: 'Tag' },
{
value: WebhookEvents.PullRequest,
text: 'Pull Request',
},
{ value: WebhookEvents.Deploy, text: 'Deploy' },
];
export default defineComponent({
name: 'GeneralTab',
@ -124,6 +112,7 @@ export default defineComponent({
const notifications = useNotifications();
const { user } = useAuthentication();
const repoStore = RepoStore();
const i18n = useI18n();
const repo = inject<Ref<Repo>>('repo');
const repoSettings = ref<RepoSettings>();
@ -164,13 +153,41 @@ export default defineComponent({
await apiClient.updateRepo(repo.value.owner, repo.value.name, repoSettings.value);
await loadRepo();
notifications.notify({ title: 'Repository settings updated', type: 'success' });
notifications.notify({ title: i18n.t('repo.settings.general.success'), type: 'success' });
});
onMounted(() => {
loadRepoSettings();
});
const projectVisibilityOptions: RadioOption[] = [
{
value: RepoVisibility.Public,
text: i18n.t('repo.settings.general.visibility.public.public'),
description: i18n.t('repo.settings.general.visibility.public.desc'),
},
{
value: RepoVisibility.Private,
text: i18n.t('repo.settings.general.visibility.private.private'),
description: i18n.t('repo.settings.general.visibility.private.desc'),
},
{
value: RepoVisibility.Internal,
text: i18n.t('repo.settings.general.visibility.internal.internal'),
description: i18n.t('repo.settings.general.visibility.internal.desc'),
},
];
const cancelPreviousBuildEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: i18n.t('repo.build.event.push') },
{ value: WebhookEvents.Tag, text: i18n.t('repo.build.event.tag') },
{
value: WebhookEvents.PullRequest,
text: i18n.t('repo.build.event.pr'),
},
{ value: WebhookEvents.Deploy, text: i18n.t('repo.build.event.deploy') },
];
return {
user,
repoSettings,

View file

@ -2,9 +2,9 @@
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<div class="ml-2">
<h1 class="text-xl text-gray-500">Registry credentials</h1>
<h1 class="text-xl text-gray-500">{{ $t('repo.settings.registries.creds') }}</h1>
<p class="text-sm text-gray-400 dark:text-gray-600">
Registries credentials can be added to use private images for your pipeline.
{{ $t('repo.settings.registries.desc') }}
<DocsLink url="docs/usage/registries" />
</p>
</div>
@ -12,10 +12,16 @@
v-if="selectedRegistry"
class="ml-auto"
start-icon="back"
text="Show registries"
:text="$t('repo.settings.registries.show')"
@click="selectedRegistry = undefined"
/>
<Button v-else class="ml-auto" start-icon="plus" text="Add registry" @click="selectedRegistry = {}" />
<Button
v-else
class="ml-auto"
start-icon="plus"
:text="$t('repo.settings.registries.add')"
@click="selectedRegistry = {}"
/>
</div>
<div v-if="!selectedRegistry" class="space-y-4 text-gray-500">
@ -30,30 +36,34 @@
/>
</ListItem>
<div v-if="registries?.length === 0" class="ml-2">There are no registry credentials yet.</div>
<div v-if="registries?.length === 0" class="ml-2">{{ $t('repo.settings.registries.none') }}</div>
</div>
<div v-else class="space-y-4">
<form @submit.prevent="createRegistry">
<InputField label="Address">
<InputField :label="$t('repo.settings.registries.address.address')">
<!-- TODO: check input field Address is a valid address -->
<TextField
v-model="selectedRegistry.address"
placeholder="Registry Address (e.g. docker.io)"
:placeholder="$t('repo.settings.registries.address.placeholder')"
required
:disabled="isEditingRegistry"
/>
</InputField>
<InputField label="Username">
<TextField v-model="selectedRegistry.username" placeholder="Username" required />
<InputField :label="$t('username')">
<TextField v-model="selectedRegistry.username" :placeholder="$t('username')" required />
</InputField>
<InputField label="Password">
<TextField v-model="selectedRegistry.password" placeholder="Password" required />
<InputField :label="$t('password')">
<TextField v-model="selectedRegistry.password" :placeholder="$t('password')" required />
</InputField>
<Button type="submit" :is-loading="isSaving" :text="isEditingRegistry ? 'Save registy' : 'Add registry'" />
<Button
type="submit"
:is-loading="isSaving"
:text="isEditingRegistry ? $t('repo.settings.registries.save') : $t('repo.settings.registries.add')"
/>
</form>
</div>
</Panel>
@ -61,6 +71,7 @@
<script lang="ts">
import { computed, defineComponent, inject, onMounted, Ref, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
import DocsLink from '~/components/atomic/DocsLink.vue';
@ -91,6 +102,7 @@ export default defineComponent({
setup() {
const apiClient = useApiClient();
const notifications = useNotifications();
const i18n = useI18n();
const repo = inject<Ref<Repo>>('repo');
const registries = ref<Registry[]>();
@ -119,7 +131,12 @@ export default defineComponent({
} else {
await apiClient.createRegistry(repo.value.owner, repo.value.name, selectedRegistry.value);
}
notifications.notify({ title: 'Registry credentials created', type: 'success' });
notifications.notify({
title: i18n.t(
isEditingRegistry.value ? 'repo.settings.registries.saved' : i18n.t('repo.settings.registries.created'),
),
type: 'success',
});
selectedRegistry.value = undefined;
await loadRegistries();
});
@ -131,7 +148,7 @@ export default defineComponent({
const registryAddress = encodeURIComponent(_registry.address);
await apiClient.deleteRegistry(repo.value.owner, repo.value.name, registryAddress);
notifications.notify({ title: 'Registry credentials deleted', type: 'success' });
notifications.notify({ title: i18n.t('repo.settings.registries.deleted'), type: 'success' });
await loadRegistries();
});

View file

@ -2,20 +2,20 @@
<Panel>
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
<div class="ml-2">
<h1 class="text-xl text-gray-500">Secrets</h1>
<h1 class="text-xl text-gray-500">{{ $t('repo.settings.secrets.secrets') }}</h1>
<p class="text-sm text-gray-400 dark:text-gray-600">
Secrets can be passed to individual pipeline steps at runtime as environmental variables.
{{ $t('repo.settings.secrets.desc') }}
<DocsLink url="docs/usage/secrets" />
</p>
</div>
<Button
v-if="selectedSecret"
class="ml-auto"
text="Show secrets"
:text="$t('repo.settings.secrets.show')"
start-icon="back"
@click="selectedSecret = undefined"
/>
<Button v-else class="ml-auto" text="Add secret" start-icon="plus" @click="showAddSecret" />
<Button v-else class="ml-auto" :text="$t('repo.settings.secrets.add')" start-icon="plus" @click="showAddSecret" />
</div>
<div v-if="!selectedSecret" class="space-y-4 text-gray-500">
@ -38,31 +38,42 @@
/>
</ListItem>
<div v-if="secrets?.length === 0" class="ml-2">There are no secrets yet.</div>
<div v-if="secrets?.length === 0" class="ml-2">{{ $t('repo.settings.secrets.none') }}</div>
</div>
<div v-else class="space-y-4">
<form @submit.prevent="createSecret">
<InputField label="Name">
<TextField v-model="selectedSecret.name" placeholder="Name" required :disabled="isEditingSecret" />
</InputField>
<InputField label="Value">
<TextField v-model="selectedSecret.value" placeholder="Value" :lines="5" required />
</InputField>
<InputField label="Available for following images">
<InputField :label="$t('repo.settings.secrets.name')">
<TextField
v-model="images"
placeholder="Comma separated list of images where this secret is available, leave empty to allow all images"
v-model="selectedSecret.name"
:placeholder="$t('repo.settings.secrets.name')"
required
:disabled="isEditingSecret"
/>
</InputField>
<InputField label="Available at following events">
<InputField :label="$t('repo.settings.secrets.value')">
<TextField
v-model="selectedSecret.value"
:placeholder="$t('repo.settings.secrets.value')"
:lines="5"
required
/>
</InputField>
<InputField :label="$t('repo.settings.secrets.images.images')">
<TextField v-model="images" :placeholder="$t('repo.settings.secrets.images.desc')" />
</InputField>
<InputField :label="$t('repo.settings.secrets.events.events')">
<CheckboxesField v-model="selectedSecret.event" :options="secretEventsOptions" />
</InputField>
<Button :is-loading="isSaving" type="submit" :text="isEditingSecret ? 'Save secret' : 'Add secret'" />
<Button
:is-loading="isSaving"
type="submit"
:text="isEditingSecret ? $t('repo.settings.secrets.save') : $t('repo.settings.secrets.add')"
/>
</form>
</div>
</Panel>
@ -71,6 +82,7 @@
<script lang="ts">
import { cloneDeep } from 'lodash';
import { computed, defineComponent, inject, onMounted, Ref, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
import DocsLink from '~/components/atomic/DocsLink.vue';
@ -93,18 +105,6 @@ const emptySecret = {
event: [WebhookEvents.Push],
};
const secretEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: 'Push' },
{ value: WebhookEvents.Tag, text: 'Tag' },
{
value: WebhookEvents.PullRequest,
text: 'Pull Request',
description:
'Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets.',
},
{ value: WebhookEvents.Deploy, text: 'Deploy' },
];
export default defineComponent({
name: 'SecretsTab',
@ -122,6 +122,7 @@ export default defineComponent({
setup() {
const apiClient = useApiClient();
const notifications = useNotifications();
const i18n = useI18n();
const repo = inject<Ref<Repo>>('repo');
const secrets = ref<Secret[]>();
@ -163,7 +164,10 @@ export default defineComponent({
} else {
await apiClient.createSecret(repo.value.owner, repo.value.name, selectedSecret.value);
}
notifications.notify({ title: 'Secret created', type: 'success' });
notifications.notify({
title: i18n.t(isEditingSecret.value ? 'repo.settings.secrets.saved' : 'repo.settings.secrets.created'),
type: 'success',
});
selectedSecret.value = undefined;
await loadSecrets();
});
@ -174,7 +178,7 @@ export default defineComponent({
}
await apiClient.deleteSecret(repo.value.owner, repo.value.name, _secret.name);
notifications.notify({ title: 'Secret deleted', type: 'success' });
notifications.notify({ title: i18n.t('repo.settings.secrets.deleted'), type: 'success' });
await loadSecrets();
});
@ -186,6 +190,17 @@ export default defineComponent({
await loadSecrets();
});
const secretEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: i18n.t('repo.build.event.push') },
{ value: WebhookEvents.Tag, text: i18n.t('repo.build.event.tag') },
{
value: WebhookEvents.PullRequest,
text: i18n.t('repo.build.event.pr'),
description: i18n.t('repo.settings.secrets.events.pr_warning'),
},
{ value: WebhookEvents.Deploy, text: i18n.t('repo.build.event.deploy') },
];
return {
secretEventsOptions,
selectedSecret,

View file

@ -1,4 +1,5 @@
import { computed, Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDate } from '~/compositions/useDate';
import { useElapsedTime } from '~/compositions/useElapsedTime';
@ -25,9 +26,10 @@ export default (build: Ref<Build | undefined>) => {
);
const { time: sinceElapsed } = useElapsedTime(sinceUnderOneHour, sinceRaw);
const i18n = useI18n();
const since = computed(() => {
if (sinceRaw.value === 0) {
return 'not started yet';
return i18n.t('time.not_started');
}
if (sinceElapsed.value === undefined) {
@ -66,7 +68,7 @@ export default (build: Ref<Build | undefined>) => {
}
if (durationRaw.value === 0) {
return 'not started yet';
return i18n.t('time.not_started');
}
return prettyDuration(durationElapsed.value);

View file

@ -1,15 +1,19 @@
import 'dayjs/locale/en';
import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { useI18n } from 'vue-i18n';
dayjs.extend(timezone);
dayjs.extend(utc);
dayjs.extend(advancedFormat);
dayjs.locale(navigator.language.split('-')[0]);
export function useDate() {
function toLocaleString(date: Date) {
return dayjs(date).format('MMM D, YYYY, HH:mm z');
return dayjs(date).format(useI18n().t('time.tmpl'));
}
return {

View file

@ -0,0 +1,12 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import messages from '@intlify/vite-plugin-vue-i18n/messages';
import { createI18n } from 'vue-i18n';
export const i18n = createI18n({
locale: navigator.language.split('-')[0],
legacy: false,
globalInjection: true,
fallbackLocale: 'en',
messages,
});

View file

@ -7,6 +7,7 @@ import { createApp } from 'vue';
import App from '~/App.vue';
import useEvents from '~/compositions/useEvents';
import { i18n } from '~/compositions/useI18n';
import { notifications } from '~/compositions/useNotifications';
import router from '~/router';
@ -14,6 +15,7 @@ const app = createApp(App);
app.use(router);
app.use(notifications);
app.use(i18n);
app.use(createPinia());
app.mount('#app');

View file

@ -1,19 +1,21 @@
import humanizeDuration from 'humanize-duration';
const enShort = {
w: () => 'w',
d: () => 'd',
h: () => 'h',
m: () => 'min',
s: () => 'sec',
};
const durationOptions: humanizeDuration.HumanizerOptions = {
round: true,
languages: { en_short: enShort },
language: 'en_short',
};
import { useI18n } from 'vue-i18n';
export function prettyDuration(durationMs: number): string {
const i18n = useI18n();
const short = {
w: () => i18n.t('time.weeks_short'),
d: () => i18n.t('time.days_short'),
h: () => i18n.t('time.hours_short'),
m: () => i18n.t('time.min_short'),
s: () => i18n.t('time.sec_short'),
};
const durationOptions: humanizeDuration.HumanizerOptions = {
round: true,
languages: { short },
language: 'short',
};
if (durationMs < 1000 * 60 * 60) {
return humanizeDuration(durationMs, durationOptions);
}

View file

@ -3,6 +3,6 @@ import en from 'javascript-time-ago/locale/en.json';
TimeAgo.addDefaultLocale(en);
const timeAgo = new TimeAgo('en-US');
const timeAgo = new TimeAgo(navigator.language);
export default timeAgo;

View file

@ -19,8 +19,8 @@
<img class="w-48 h-48" src="../assets/logo.svg?url" />
</div>
<div class="flex flex-col my-8 md:w-2/5 p-4 items-center justify-center">
<h1 class="text-xl text-gray-600 dark:text-gray-500">Welcome to Woodpecker</h1>
<Button class="mt-4" @click="doLogin">Login</Button>
<h1 class="text-xl text-gray-600 dark:text-gray-500">{{ $t('welcome') }}</h1>
<Button class="mt-4" @click="doLogin">{{ $t('login') }}</Button>
</div>
</div>
</div>
@ -28,17 +28,12 @@
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue';
import useAuthentication from '~/compositions/useAuthentication';
const authErrorMessages = {
oauth_error: 'Error while authenticating against OAuth provider',
internal_error: 'Some internal error occured',
access_denied: 'You are not allowed to login',
};
export default defineComponent({
name: 'Login',
@ -51,12 +46,19 @@ export default defineComponent({
const router = useRouter();
const authentication = useAuthentication();
const errorMessage = ref<string>();
const i18n = useI18n();
function doLogin() {
const url = typeof route.query.url === 'string' ? route.query.url : '';
authentication.authenticate(url);
}
const authErrorMessages = {
oauth_error: i18n.t('user.oauth_error'),
internal_error: i18n.t('user.internal_error'),
access_denied: i18n.t('user.access_denied'),
};
onMounted(async () => {
if (authentication.isAuthenticated) {
await router.replace({ name: 'home' });

View file

@ -1,7 +1,11 @@
<template>
<div class="flex flex-col h-full w-full items-center justify-center">
<p class="text-2xl mb-8">Whoa 404, either we broke something or you had a typing mishap :-/</p>
<span>Back to <router-link class="text-blue-400" replace :to="{ name: 'home' }">home</router-link></span>
<p class="text-2xl mb-8">{{ $t('not_found.not_found') }}</p>
<span
><router-link class="text-blue-400" replace :to="{ name: 'home' }">{{
$t('not_found.back_home')
}}</router-link></span
>
</div>
</template>

View file

@ -2,12 +2,12 @@
<FluidContainer class="flex flex-col">
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-dark-200">
<IconButton :to="{ name: 'repos' }" icon="back" />
<h1 class="text-xl ml-2 text-gray-500">Add repository</h1>
<TextField v-model="search" class="w-auto ml-auto" placeholder="Search ..." />
<h1 class="text-xl ml-2 text-gray-500">{{ $t('repo.add') }}</h1>
<TextField v-model="search" class="w-auto ml-auto" :placeholder="$t('search')" />
<Button
class="ml-auto"
start-icon="sync"
text="Reload repositories"
:text="$t('repo.enable.reload')"
:is-loading="isReloadingRepos"
@click="reloadRepos"
/>
@ -22,11 +22,11 @@
@click="repo.active && $router.push({ name: 'repo', params: { repoOwner: repo.owner, repoName: repo.name } })"
>
<span class="text-gray-500">{{ repo.full_name }}</span>
<span v-if="repo.active" class="ml-auto text-gray-500">Already enabled</span>
<span v-if="repo.active" class="ml-auto text-gray-500">{{ $t('repo.enable.enabled') }}</span>
<Button
v-if="!repo.active"
class="ml-auto"
text="Enable"
:text="$t('repo.enable.enable')"
:is-loading="isActivatingRepo && repoToActivate?.id === repo.id"
@click="activateRepo(repo)"
/>
@ -37,6 +37,7 @@
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue';
@ -68,6 +69,7 @@ export default defineComponent({
const repos = ref<Repo[]>();
const repoToActivate = ref<Repo>();
const search = ref('');
const i18n = useI18n();
const { searchedRepos } = useRepoSearch(repos, search);
@ -78,13 +80,13 @@ export default defineComponent({
const { doSubmit: reloadRepos, isLoading: isReloadingRepos } = useAsyncAction(async () => {
repos.value = undefined;
repos.value = await apiClient.getRepoList({ all: true, flush: true });
notifications.notify({ title: 'Repository list reloaded', type: 'success' });
notifications.notify({ title: i18n.t('repo.enable.list_reloaded'), type: 'success' });
});
const { doSubmit: activateRepo, isLoading: isActivatingRepo } = useAsyncAction(async (repo: Repo) => {
repoToActivate.value = repo;
await apiClient.activateRepo(repo.owner, repo.name);
notifications.notify({ title: 'Repository enabled', type: 'success' });
notifications.notify({ title: i18n.t('repo.enabled.success'), type: 'success' });
repoToActivate.value = undefined;
await router.push({ name: 'repo', params: { repoName: repo.name, repoOwner: repo.owner } });
});

View file

@ -2,8 +2,8 @@
<FluidContainer class="flex flex-col">
<div class="flex flex-row flex-wrap md:grid md:grid-cols-3 border-b pb-4 mb-4 dark:border-dark-200">
<h1 class="text-xl text-gray-500">Repositories</h1>
<TextField v-model="search" class="w-auto md:ml-auto md:mr-auto" placeholder="Search ..." />
<Button class="md:ml-auto" :to="{ name: 'repo-add' }" start-icon="plus" text="Add repository" />
<TextField v-model="search" class="w-auto md:ml-auto md:mr-auto" :placeholder="$t('search')" />
<Button class="md:ml-auto" :to="{ name: 'repo-add' }" start-icon="plus" :text="$t('repo.add')" />
</div>
<div class="space-y-4">

View file

@ -2,7 +2,7 @@
<FluidContainer class="flex flex-col">
<div class="flex flex-row flex-wrap md:grid md:grid-cols-3 border-b pb-4 mb-4 dark:border-dark-200">
<h1 class="text-xl text-gray-500">{{ repoOwner }}</h1>
<TextField v-model="search" class="w-auto md:ml-auto md:mr-auto" placeholder="Search ..." />
<TextField v-model="search" class="w-auto md:ml-auto md:mr-auto" :placeholder="$t('search')" />
</div>
<div class="space-y-4">
@ -16,7 +16,7 @@
</ListItem>
</div>
<div v-if="(searchedRepos || []).length <= 0" class="text-center">
<span class="text-gray-500 m-auto">This organization / user does not have any projects yet.</span>
<span class="text-gray-500 m-auto">{{ $t('repo.user_none') }}</span>
</div>
</FluidContainer>
</template>

View file

@ -1,26 +1,26 @@
<template>
<FluidContainer class="space-y-4 flex flex-col my-0">
<Button class="ml-auto" text="Logout" :to="`${address}/logout`" />
<Button class="ml-auto" :text="$t('logout')" :to="`${address}/logout`" />
<div>
<h2 class="text-lg text-gray-500">Your Personal Token</h2>
<h2 class="text-lg text-gray-500">{{ $t('user.token') }}</h2>
<pre class="cli-box">{{ token }}</pre>
</div>
<div>
<h2 class="text-lg text-gray-500">Shell setup</h2>
<h2 class="text-lg text-gray-500">{{ $t('user.shell_setup') }}</h2>
<pre class="cli-box">{{ usageWithShell }}</pre>
</div>
<div>
<h2 class="text-lg text-gray-500">Example API Usage</h2>
<h2 class="text-lg text-gray-500">{{ $t('user.api_usage') }}</h2>
<pre class="cli-box">{{ usageWithCurl }}</pre>
</div>
<div>
<div class="flex items-center">
<h2 class="text-lg text-gray-500">Example CLI Usage</h2>
<a :href="cliDownload" target="_blank" class="ml-4 text-link">Download CLI</a>
<h2 class="text-lg text-gray-500">{{ $t('user.cli_usage') }}</h2>
<a :href="cliDownload" target="_blank" class="ml-4 text-link">{{ $t('user.dl_cli') }}</a>
</div>
<pre class="cli-box">{{ usageWithCli }}</pre>
</div>
@ -29,6 +29,7 @@
<script lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
import FluidContainer from '~/components/layout/FluidContainer.vue';
@ -61,9 +62,11 @@ export default defineComponent({
const usageWithCurl =
// eslint-disable-next-line no-template-curly-in-string
'# do shell setup steps before\ncurl -i ${WOODPECKER_SERVER}/api/user -H "Authorization: Bearer ${WOODPECKER_TOKEN}"';
`# ${useI18n().t(
'user.shell_setup_before',
)}\ncurl -i \${WOODPECKER_SERVER}/api/user -H "Authorization: Bearer \${WOODPECKER_TOKEN}"`;
const usageWithCli = '# do shell setup steps before\nwoodpecker info';
const usageWithCli = `# ${useI18n().t('user.shell_setup_before')}\nwoodpecker info`;
const cliDownload = 'https://github.com/woodpecker-ci/woodpecker/releases';

View file

@ -1,6 +1,6 @@
<template>
<div class="flex w-full mb-4 justify-center">
<span class="text-gray-600 dark:text-gray-500 text-xl">Pipelines for branch "{{ branch }}"</span>
<span class="text-gray-600 dark:text-gray-500 text-xl">{{ $t('repo.build.pipelines_for', [branch]) }}</span>
</div>
<BuildList :builds="builds" :repo="repo" />
</template>

View file

@ -2,23 +2,23 @@
<FluidContainer>
<div class="flex border-b items-center pb-4 mb-4 dark:border-gray-600">
<IconButton icon="back" @click="goBack" />
<h1 class="text-xl ml-2 text-gray-500">Settings</h1>
<h1 class="text-xl ml-2 text-gray-500">{{ $t('repo.settings.settings') }}</h1>
</div>
<Tabs>
<Tab title="General">
<Tab :title="$t('repo.settings.general.general')">
<GeneralTab />
</Tab>
<Tab title="Secrets">
<Tab :title="$t('repo.settings.secrets.secrets')">
<SecretsTab />
</Tab>
<Tab title="Registries">
<Tab :title="$t('repo.settings.registries.registries')">
<RegistriesTab />
</Tab>
<Tab title="Badge">
<Tab :title="$t('repo.settings.badge.badge')">
<BadgeTab />
</Tab>
<Tab title="Actions">
<Tab :title="$t('repo.settings.actions.actions')">
<ActionsTab />
</Tab>
</Tabs>

View file

@ -20,8 +20,8 @@
</div>
<Tabs v-model="activeTab" disable-hash-mode class="mb-4">
<Tab title="Activity" />
<Tab title="Branches" />
<Tab :title="$t('repo.activity')" />
<Tab :title="$t('repo.branches')" />
</Tabs>
<router-view />
@ -31,6 +31,7 @@
<script lang="ts">
import { computed, defineComponent, onMounted, provide, ref, toRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import Icon from '~/components/atomic/Icon.vue';
@ -76,6 +77,7 @@ export default defineComponent({
const { isAuthenticated } = useAuthentication();
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const repo = repoStore.getRepo(repoOwner, repoName);
const repoPermissions = ref<RepoPermissions>();
@ -87,7 +89,7 @@ export default defineComponent({
async function loadRepo() {
repoPermissions.value = await apiClient.getRepoPermissions(repoOwner.value, repoName.value);
if (!repoPermissions.value.pull) {
notifications.notify({ type: 'error', title: 'Not allowed to access this repository' });
notifications.notify({ type: 'error', title: i18n.t('repo.not_allowed') });
// no access and not authenticated, redirect to login
if (!isAuthenticated) {
await router.replace({ name: 'login', query: { url: route.fullPath } });

View file

@ -5,22 +5,32 @@
<div class="flex flex-grow relative">
<div v-if="build.error" class="flex flex-col p-4">
<span class="text-red-400 font-bold text-xl mb-2">Execution error</span>
<span class="text-red-400 font-bold text-xl mb-2">{{ $t('repo.build.execution_error') }}</span>
<span class="text-red-400">{{ build.error }}</span>
</div>
<div v-else-if="build.status === 'blocked'" class="flex flex-col flex-grow justify-center items-center">
<Icon name="status-blocked" class="w-32 h-32 text-gray-500" />
<p class="text-xl text-gray-500">This pipeline is awaiting approval by some maintainer!</p>
<p class="text-xl text-gray-500">{{ $t('repo.build.protected.awaits') }}</p>
<div v-if="repoPermissions.push" class="flex mt-2 space-x-4">
<Button color="green" text="Approve" :is-loading="isApprovingBuild" @click="approveBuild" />
<Button color="red" text="Decline" :is-loading="isDecliningBuild" @click="declineBuild" />
<Button
color="green"
:text="$t('repo.build.protected.approve')"
:is-loading="isApprovingBuild"
@click="approveBuild"
/>
<Button
color="red"
:text="$t('repo.build.protected.decline')"
:is-loading="isDecliningBuild"
@click="declineBuild"
/>
</div>
</div>
<div v-else-if="build.status === 'declined'" class="flex flex-col flex-grow justify-center items-center">
<Icon name="status-blocked" class="w-32 h-32 text-gray-500" />
<p class="text-xl text-gray-500">This pipeline has been declined!</p>
<p class="text-xl text-gray-500">{{ $t('repo.build.protected.declined') }}</p>
</div>
<BuildLog

View file

@ -2,7 +2,7 @@
<FluidContainer v-if="build" class="flex flex-col gap-y-6 text-gray-500 justify-between py-0">
<Panel>
<div v-if="build.changed_files === undefined || build.changed_files.length < 1" class="w-full">
<span class="text-gray-500">No files have been changed.</span>
<span class="text-gray-500">{{ $t('repo.build.no_files') }}</span>
</div>
<div v-for="file in build.changed_files" v-else :key="file" class="w-full">
<div>- {{ file }}</div>

View file

@ -27,14 +27,14 @@
<Button
v-if="build.status === 'pending' || build.status === 'running'"
class="ml-4 flex-shrink-0"
text="Cancel"
:text="$t('repo.build.actions.cancel')"
:is-loading="isCancelingBuild"
@click="cancelBuild"
/>
<Button
v-else-if="build.status !== 'blocked' && build.status !== 'declined'"
class="ml-4 flex-shrink-0"
text="Restart"
:text="$t('repo.build.actions.restart')"
:is-loading="isRestartingBuild"
@click="restartBuild"
/>
@ -43,9 +43,9 @@
<div class="flex flex-wrap gap-y-2 items-center justify-between">
<Tabs v-model="activeTab" disable-hash-mode class="order-2 md:order-none">
<Tab id="tasks" title="Tasks" />
<Tab id="config" title="Config" />
<Tab id="changed-files" :title="`Changed files (${build.changed_files?.length || 0})`" />
<Tab id="tasks" :title="$t('repo.build.tasks')" />
<Tab id="config" :title="$t('repo.build.config')" />
<Tab id="changed-files" :title="$t('repo.build.files', [build.changed_files?.length || 0])" />
</Tabs>
<div class="flex justify-between gap-x-4 text-gray-500 flex-shrink-0 pb-2 md:p-0 mx-auto md:mr-0">
@ -53,7 +53,9 @@
<Icon name="since" />
<Tooltip>
<span>{{ since }}</span>
<template #popper><span class="font-bold">Created</span> {{ created }}</template>
<template #popper
><span class="font-bold">{{ $t('repo.build.created') }}</span> {{ created }}</template
>
</Tooltip>
</div>
<div class="flex space-x-1 items-center flex-shrink-0">
@ -82,6 +84,7 @@ import {
toRef,
watch,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue';
@ -146,6 +149,7 @@ export default defineComponent({
const router = useRouter();
const notifications = useNotifications();
const favicon = useFavicon();
const i18n = useI18n();
const buildStore = BuildStore();
const buildId = toRef(props, 'buildId');
@ -190,7 +194,7 @@ export default defineComponent({
// }
await apiClient.cancelBuild(repo.value.owner, repo.value.name, parseInt(buildId.value, 10), 0);
notifications.notify({ title: 'Pipeline canceled', type: 'success' });
notifications.notify({ title: i18n.t('repo.build.actions.cancel_success'), type: 'success' });
});
const { doSubmit: restartBuild, isLoading: isRestartingBuild } = useAsyncAction(async () => {
@ -199,7 +203,7 @@ export default defineComponent({
}
await apiClient.restartBuild(repo.value.owner, repo.value.name, buildId.value, { fork: true });
notifications.notify({ title: 'Pipeline restarted', type: 'success' });
notifications.notify({ title: i18n.t('repo.build.actions.restart_success'), type: 'success' });
// TODO: directly send to newest build?
await router.push({ name: 'repo', params: { repoName: repo.value.name, repoOwner: repo.value.owner } });
});

View file

@ -1,4 +1,5 @@
/* eslint-disable import/no-extraneous-dependencies */
import vueI18n from '@intlify/vite-plugin-vue-i18n';
import vue from '@vitejs/plugin-vue';
import path from 'path';
import IconsResolver from 'unplugin-icons/resolver';
@ -25,6 +26,9 @@ function woodpeckerInfoPlugin() {
export default defineConfig({
plugins: [
vue(),
vueI18n({
include: path.resolve(__dirname, 'src/assets/locales/**'),
}),
WindiCSS(),
Icons(),
svgLoader(),

View file

@ -133,6 +133,85 @@
kolorist "^1.5.0"
local-pkg "^0.4.0"
"@intlify/bundle-utils@^2.2.2":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@intlify/bundle-utils/-/bundle-utils-2.2.2.tgz#fe65ce2549a73b99b75f3e66209d741b4f4d61fd"
integrity sha512-vngkvlIVV8ZJoyC5VqMvqJd2nvsx+qMN7pQjPiPjOrVndeiR7Dlue0k86Q8FsFUzyksW3HJZZi833ldxwbFzTA==
dependencies:
"@intlify/message-compiler" "^9.1.0"
"@intlify/shared" "^9.1.0"
jsonc-eslint-parser "^1.0.1"
source-map "^0.6.1"
yaml-eslint-parser "^0.3.2"
"@intlify/core-base@9.1.10":
version "9.1.10"
resolved "https://registry.yarnpkg.com/@intlify/core-base/-/core-base-9.1.10.tgz#cbd3099f375c789a1b974f3ea79b6efb8bb148fa"
integrity sha512-So9CNUavB/IsZ+zBmk2Cv6McQp6vc2wbGi1S0XQmJ8Vz+UFcNn9MFXAe9gY67PreIHrbLsLxDD0cwo1qsxM1Nw==
dependencies:
"@intlify/devtools-if" "9.1.10"
"@intlify/message-compiler" "9.1.10"
"@intlify/message-resolver" "9.1.10"
"@intlify/runtime" "9.1.10"
"@intlify/shared" "9.1.10"
"@intlify/vue-devtools" "9.1.10"
"@intlify/devtools-if@9.1.10":
version "9.1.10"
resolved "https://registry.yarnpkg.com/@intlify/devtools-if/-/devtools-if-9.1.10.tgz#8704852a4fa547df43df71a16b1cc4b27e758aa3"
integrity sha512-SHaKoYu6sog3+Q8js1y3oXLywuogbH1sKuc7NSYkN3GElvXSBaMoCzW+we0ZSFqj/6c7vTNLg9nQ6rxhKqYwnQ==
dependencies:
"@intlify/shared" "9.1.10"
"@intlify/message-compiler@9.1.10", "@intlify/message-compiler@^9.1.0":
version "9.1.10"
resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-9.1.10.tgz#271f5e1cb65f3cec4b1fb243e50615747613f4be"
integrity sha512-+JiJpXff/XTb0EadYwdxOyRTB0hXNd4n1HaJ/a4yuV960uRmPXaklJsedW0LNdcptd/hYUZtCkI7Lc9J5C1gxg==
dependencies:
"@intlify/message-resolver" "9.1.10"
"@intlify/shared" "9.1.10"
source-map "0.6.1"
"@intlify/message-resolver@9.1.10":
version "9.1.10"
resolved "https://registry.yarnpkg.com/@intlify/message-resolver/-/message-resolver-9.1.10.tgz#fb1dabdec2e29942df26f47e19444278a6e2f070"
integrity sha512-5YixMG/M05m0cn9+gOzd4EZQTFRUu8RGhzxJbR1DWN21x/Z3bJ8QpDYj6hC4FwBj5uKsRfKpJQ3Xqg98KWoA+w==
"@intlify/runtime@9.1.10":
version "9.1.10"
resolved "https://registry.yarnpkg.com/@intlify/runtime/-/runtime-9.1.10.tgz#70582a16810f68953d1cbf7183c8107a9137b580"
integrity sha512-7QsuByNzpe3Gfmhwq6hzgXcMPpxz8Zxb/XFI6s9lQdPLPe5Lgw4U1ovRPZTOs6Y2hwitR3j/HD8BJNGWpJnOFA==
dependencies:
"@intlify/message-compiler" "9.1.10"
"@intlify/message-resolver" "9.1.10"
"@intlify/shared" "9.1.10"
"@intlify/shared@9.1.10", "@intlify/shared@^9.1.0":
version "9.1.10"
resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-9.1.10.tgz#9e2527276b43ae3f354c4015eb04f855d9d7a707"
integrity sha512-Om54xJeo1Vw+K1+wHYyXngE8cAbrxZHpWjYzMR9wCkqbhGtRV5VLhVc214Ze2YatPrWlS2WSMOWXR8JktX/IgA==
"@intlify/vite-plugin-vue-i18n@^3.4.0":
version "3.4.0"
resolved "https://registry.yarnpkg.com/@intlify/vite-plugin-vue-i18n/-/vite-plugin-vue-i18n-3.4.0.tgz#cf15b0d207a843227a5da0ac713f1a5b9d96e40b"
integrity sha512-XXcZBgwJ+3FRu11c4ARoY9N00kElPii0/jNZ49qR045Ka7/YGCwb1Ku14BBlMSEHiHDSjLQknLwrJKSQGVZLyA==
dependencies:
"@intlify/bundle-utils" "^2.2.2"
"@intlify/shared" "^9.1.0"
"@rollup/pluginutils" "^4.1.0"
debug "^4.3.1"
fast-glob "^3.2.5"
source-map "0.6.1"
"@intlify/vue-devtools@9.1.10":
version "9.1.10"
resolved "https://registry.yarnpkg.com/@intlify/vue-devtools/-/vue-devtools-9.1.10.tgz#c62535d86742bcd16593806a4fcae49f6fc8ae6d"
integrity sha512-5l3qYARVbkWAkagLu1XbDUWRJSL8br1Dj60wgMaKB0+HswVsrR6LloYZTg7ozyvM621V6+zsmwzbQxbVQyrytQ==
dependencies:
"@intlify/message-resolver" "9.1.10"
"@intlify/runtime" "9.1.10"
"@intlify/shared" "9.1.10"
"@kyvg/vue3-notification@2.3.4":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@kyvg/vue3-notification/-/vue3-notification-2.3.4.tgz#7503647ae1d26a7c58bbf5182b505ca345c0882a"
@ -167,6 +246,14 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@rollup/pluginutils@^4.1.0":
version "4.2.1"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==
dependencies:
estree-walker "^2.0.1"
picomatch "^2.2.2"
"@rollup/pluginutils@^4.1.1":
version "4.1.2"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.1.2.tgz#ed5821c15e5e05e32816f5fb9ec607cdf5a75751"
@ -438,6 +525,11 @@
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.1.tgz#c198200b9f84c7b6f7c4976b0206cbe1e7f61dc9"
integrity sha512-V2BKGa9pHf/sY2oBUr4uO8yF5MtgL2X96uJq2cBPxPqEUEkLfhJrbpU7t34JRjnanp2tkDJQrQsrsoMltHnFNQ==
"@vue/devtools-api@^6.0.0-beta.7":
version "6.1.4"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.1.4.tgz#b4aec2f4b4599e11ba774a50c67fa378c9824e53"
integrity sha512-IiA0SvDrJEgXvVxjNkHPFfDx6SXw0b/TUkqMcDZWNg9fnCAHbTpoo59YfJ9QLFkwa3raau5vSlRVzMSLDnfdtQ==
"@vue/reactivity-transform@3.2.30":
version "3.2.30"
resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.30.tgz#2006e9f4645777a481b78ae77fc486159afa8480"
@ -536,7 +628,7 @@ acorn-jsx@^5.2.0, acorn-jsx@^5.3.1:
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^7.1.1, acorn@^7.4.0:
acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1:
version "7.4.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
@ -893,6 +985,13 @@ debug@^4.0.1, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3:
dependencies:
ms "2.1.2"
debug@^4.3.1:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
debug@~3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
@ -1341,7 +1440,7 @@ eslint@7.32.0:
text-table "^0.2.0"
v8-compile-cache "^2.0.3"
espree@^6.2.1:
espree@^6.0.0, espree@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a"
integrity sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==
@ -1423,7 +1522,7 @@ fast-diff@^1.1.2, fast-diff@^1.2.0:
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-glob@^3.2.7, fast-glob@^3.2.9:
fast-glob@^3.2.5, fast-glob@^3.2.7, fast-glob@^3.2.9:
version "3.2.11"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
@ -1876,6 +1975,17 @@ json5@^1.0.1:
dependencies:
minimist "^1.2.0"
jsonc-eslint-parser@^1.0.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jsonc-eslint-parser/-/jsonc-eslint-parser-1.4.1.tgz#8cbe99f6f5199acbc5a823c4c0b6135411027fa6"
integrity sha512-hXBrvsR1rdjmB2kQmUjf1rEIa+TqHBGMge8pwi++C+Si1ad7EjZrJcpgwym+QGK/pqTx+K7keFAtLlVNdLRJOg==
dependencies:
acorn "^7.4.1"
eslint-utils "^2.1.0"
eslint-visitor-keys "^1.3.0"
espree "^6.0.0"
semver "^6.3.0"
jsonc-parser@^2.3.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-2.3.1.tgz#59549150b133f2efacca48fe9ce1ec0659af2342"
@ -1963,7 +2073,7 @@ lodash.truncate@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
lodash@4.17.21, lodash@^4.17.19, lodash@^4.17.21:
lodash@4.17.21, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -2579,7 +2689,7 @@ source-map-url@^0.4.0:
resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56"
integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==
source-map@^0.6.1:
source-map@0.6.1, source-map@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
@ -3033,6 +3143,16 @@ vue-eslint-parser@^7.10.0:
lodash "^4.17.21"
semver "^6.3.0"
vue-i18n@9:
version "9.1.10"
resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-9.1.10.tgz#7ad516b89ba28debb90fc4181c9a2faec9ad97f9"
integrity sha512-jpr7gV5KPk4n+sSPdpZT8Qx3XzTcNDWffRlHV/cT2NUyEf+sEgTTmLvnBAibjOFJ0zsUyZlVTAWH5DDnYep+1g==
dependencies:
"@intlify/core-base" "9.1.10"
"@intlify/shared" "9.1.10"
"@intlify/vue-devtools" "9.1.10"
"@vue/devtools-api" "^6.0.0-beta.7"
vue-resize@^2.0.0-alpha.1:
version "2.0.0-alpha.1"
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz#43eeb79e74febe932b9b20c5c57e0ebc14e2df3a"
@ -3127,6 +3247,20 @@ yallist@^4.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml-eslint-parser@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/yaml-eslint-parser/-/yaml-eslint-parser-0.3.2.tgz#c7f5f3904f1c06ad55dc7131a731b018426b4898"
integrity sha512-32kYO6kJUuZzqte82t4M/gB6/+11WAuHiEnK7FreMo20xsCKPeFH5tDBU7iWxR7zeJpNnMXfJyXwne48D0hGrg==
dependencies:
eslint-visitor-keys "^1.3.0"
lodash "^4.17.20"
yaml "^1.10.0"
yaml@^1.10.0:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"