Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
isUnknown
8a7ed28366 fix : getEndTime défensif + force re-déploiement de program.php
All checks were successful
Deploy / Deploy (push) Successful in 8s
La CI étant nouvelle (mode incrémental depuis le 1er push), le serveur avait
une ancienne version de program.php qui appelait encore getEndTime(). Cette
version a été supprimée localement dans le commit a6631f6 mais jamais
redéployée. On force le fichier dans le diff ET on rend getEndTime robuste.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:35:47 +02:00
isUnknown
41da8ff773 fix : lien billetterie hero — URL Mapado et bookingUrl corrigés
All checks were successful
Deploy / Deploy (push) Successful in 9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:29:12 +02:00
isUnknown
0afe0d19d5 header : mise à jour du snippet
All checks were successful
Deploy / Deploy (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:24:58 +02:00
isUnknown
1d01de1217 styles : mise à jour CSS et JS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:24:54 +02:00
isUnknown
f7bb3e86d4 fix : schedule() retourne '' si sessions invalides, filtre les brouillons sur la home
All checks were successful
Deploy / Deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:22:39 +02:00
isUnknown
9fc1f6f9b4 ci : ajout du workflow Forgejo deploy (preprod → PREPRODUCTION_HOST, main → PRODUCTION_HOST)
All checks were successful
Deploy / Deploy (push) Successful in 17s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:01:57 +02:00
isUnknown
8725c590b4 hero : refonte du mode event et custom
Mode event : lien vers un spectacle (pages field, multiple: false), affichage titre + auteurs + ticket/dates en front.
Mode custom : heroTextLeft (blocks) à gauche, heroTextRight (writer) + heroLink à droite. Suppression heroLinkText.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:35:55 +02:00
8776c089e4 création du nouveau block links : big-links-list 2026-02-17 11:34:33 +01:00
54bd4fbc87 petites modifs rapide fait pendant la revue avec adrien 2026-02-05 11:11:31 +01:00
fc828997d0 vue programme fonctionne avec les filtre de categorie. Plus de switch vers vu calendrier. Pour autant si déjà dans calendrier ça continu de fonctionner comme avant 2026-01-28 19:07:18 +01:00
3900966800 builder avec section > suppression du bandeau qui contient le titre de la page - donc le titre du site passe en h1 sur les pages sectionnées aussi, anchors-strip sticky 2026-01-27 17:33:06 +01:00
f0158eea5b event - suppression du bandeau qui contient les informations dates, durée etc 2026-01-27 16:11:51 +01:00
f771bb3f24 ajout d'un toggle pour désactiver le bandeau du haut de la page 2026-01-27 16:11:17 +01:00
114 changed files with 22891 additions and 104 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dir \"C:\\\\Users\\\\anton\\\\Desktop\\\\studioVariable\\\\NTB\\\\nouveau-theatre-de-besancon\\\\site\\\\cache\")"
]
}
}

View file

@ -0,0 +1,83 @@
name: Deploy
on:
push:
branches:
- main
- preprod
jobs:
deploy:
name: Deploy
runs-on: docker
container:
image: forgejo-ci-php:latest
steps:
- name: Checkout code
run: |
git clone --depth 50 --branch ${{ github.ref_name }} https://oauth2:${{ github.token }}@forge.studio-variable.com/${{ github.repository }}.git .
- name: Install dependencies
run: |
composer install --no-dev --optimize-autoloader
- name: Set deploy host
id: host
shell: bash
run: |
if [ "${{ github.ref_name }}" = "preprod" ]; then
echo "ftp_host=${{ secrets.PREPRODUCTION_HOST }}" >> $GITHUB_OUTPUT
else
echo "ftp_host=${{ secrets.PRODUCTION_HOST }}" >> $GITHUB_OUTPUT
fi
- name: Deploy via FTP
env:
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
FTP_HOST: ${{ steps.host.outputs.ftp_host }}
shell: bash
run: |
BEFORE="${{ github.event.before }}"
ZEROS="0000000000000000000000000000000000000000"
if [ "$BEFORE" != "$ZEROS" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
INCREMENTAL=true
echo "=== Mode incrémental — fichiers modifiés depuis $BEFORE ==="
git diff --name-status "$BEFORE" HEAD
echo "============================================================"
else
INCREMENTAL=false
echo "=== Mode full mirror (premier push ou BEFORE hors portée) ==="
fi
{
echo "set ftp:ssl-allow no"
echo "set cmd:fail-exit no"
echo "open -u $USERNAME,$PASSWORD $FTP_HOST"
if $INCREMENTAL; then
git diff --name-status "$BEFORE" HEAD | while IFS=$'\t' read -r status file; do
case "$file" in local/*|site/accounts/*|site/cache/*|site/sessions/*) continue ;; esac
if [ "$status" = "D" ]; then
echo "rm -f \"$file\""
elif [ -f "$file" ]; then
echo "mkdir -p \"$(dirname "$file")\""
echo "put \"$file\" -o \"$file\""
fi
done
else
echo "mirror --reverse --verbose --ignore-time --parallel=10 -x local/ assets assets"
echo "mirror --reverse --verbose --ignore-time --parallel=10 -x accounts/ -x cache/ -x sessions/ site site"
fi
echo "mirror --reverse --verbose --ignore-time --parallel=10 kirby kirby"
echo "mirror --reverse --verbose --ignore-time --parallel=10 vendor vendor"
echo "quit"
} > /tmp/lftp-script.txt
echo "=== Script lftp généré ==="
cat /tmp/lftp-script.txt
echo "=========================="
lftp -f /tmp/lftp-script.txt

View file

@ -2,25 +2,21 @@
--empty-space: 11.9rem; --empty-space: 11.9rem;
background-color: var(--color-beige-light); background-color: var(--color-beige-light);
--padding-vertical: calc(var(--space-m) / 1.5); --padding-vertical: calc(var(--space-m) / 1.5);
display: grid; /* height: calc(100vh - var(--empty-space)); */
grid-template-columns: 1.6fr 3fr;
column-gap: calc(var(--space-m) / 1.5);
height: calc(100vh - var(--empty-space));
overflow: hidden; overflow: hidden;
} }
.hero__text { .hero__text {
position: relative; position: relative;
padding: var(--padding-vertical) var(--space-m);
display: flex; display: flex;
flex-direction: column;
justify-content: space-between; justify-content: space-between;
overflow: auto; align-items: flex-end;
} }
.hero__text h2 { .hero__text h2 {
font-size: max(4.167vw, 3rem); font-size: max(4.167vw, 3rem);
width: 100%; width: 100%;
margin-bottom: var(--padding-vertical);
} }
.hero__text h2.big { .hero__text h2.big {
@ -39,14 +35,11 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.hero__text p:first-of-type {
margin-top: var(--padding-vertical);
}
.hero__link { .hero__link {
font-weight: bold; font-weight: bold;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.hero__link::before { .hero__link::before {
display: inline-block; display: inline-block;
content: "→"; content: "→";

View file

@ -1,5 +1,10 @@
.anchors-strip { .anchors-strip {
padding: calc(var(--padding-vertical) / 1.6) var(--space-m) !important; padding: calc(var(--padding-vertical) / 1.6) var(--space-m) !important;
position: sticky;
top: var(--header-height, 0px);
z-index: 9;
background-color: #fff !important;
box-shadow: 0px -10px #fff; /*pour éviter un petit bug du à la latence de anchors-strip_stiky.js*/
} }
.anchors-strip ul { .anchors-strip ul {
@ -68,12 +73,39 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
} }
[data-template="sectioned"] .links-list li a::before { [data-template="sectioned"] .links-list li a::before
{
content: "→"; content: "→";
display: inline-block; display: inline-block;
transform: rotate(-45deg); transform: rotate(-45deg);
} }
/*big links list*/
[data-template="sectioned"] .big-links-list li {
position: relative;
display: flex;
border-bottom: 1px solid #000;
font-size: var(--font-size-h3);
margin-bottom: 0.7rem;
padding-bottom: 0.4rem;
}
[data-template="sectioned"] .big-links-list li .li_arrow{
transition: opacity 0.3s, width 0.4s;
opacity: 0;
width: 0;
}
[data-template="sectioned"] .big-links-list li:hover .li_arrow{
opacity: 1;
width: 2.5rem;
}
[data-template="sectioned"] .big-links-list li a::before{
content: "";
position: absolute;
inset: 0;
}
.section__row.fixed-img-height img { .section__row.fixed-img-height img {
max-height: var(--height); max-height: var(--height);
object-fit: contain; object-fit: contain;

View file

@ -5,3 +5,9 @@
row-gap: var(--padding-vertical); row-gap: var(--padding-vertical);
} }
} }
@media screen and (max-width: 800px) {
.hero__text {
display: block;
}
}

View file

@ -2,7 +2,7 @@
scroll-behavior: smooth; scroll-behavior: smooth;
} }
section:not(.collapsable), section:not(.collapsable, .hero),
footer { footer {
box-sizing: border-box; box-sizing: border-box;
padding: var(--padding-vertical) var(--space-m); padding: var(--padding-vertical) var(--space-m);
@ -10,6 +10,9 @@ footer {
section:not(:last-child, .collapsable, .collapsable-sections) { section:not(:last-child, .collapsable, .collapsable-sections) {
border-bottom: var(--border); border-bottom: var(--border);
} }
.temporality-wrapper:not(:last-of-type) section.forced-border {
border-bottom: var(--border);
}
picture { picture {
background-color: var(--color); background-color: var(--color);

View file

@ -0,0 +1,15 @@
const header = document.querySelector('.main-header');
console.log("test");
const setHeaderHeight = () => {
document.documentElement.style.setProperty(
'--header-height',
`${header.offsetHeight}px`
);
};
setHeaderHeight();
const resizeObserver = new ResizeObserver(setHeaderHeight);
resizeObserver.observe(header);

View file

@ -12,10 +12,6 @@ document.addEventListener("DOMContentLoaded", () => {
jsLinks: document.querySelectorAll(".js-link"), jsLinks: document.querySelectorAll(".js-link"),
}; };
setTimeout(() => {
nodes.images = document.querySelectorAll("img");
}, 0);
// Listeners // Listeners
nodes.burgerBtn.addEventListener("click", () => expandNav(nodes)); nodes.burgerBtn.addEventListener("click", () => expandNav(nodes));
nodes.closeNavBtn.addEventListener("click", () => closeNav(nodes)); nodes.closeNavBtn.addEventListener("click", () => closeNav(nodes));
@ -24,17 +20,13 @@ document.addEventListener("DOMContentLoaded", () => {
jsLink.addEventListener("click", () => (location.href = to)); jsLink.addEventListener("click", () => (location.href = to));
}); });
setTimeout(() => { document.querySelectorAll("img").forEach((image) => {
nodes.images.forEach((image) => { if (image.complete && image.naturalWidth > 0) {
if (image.complete) { show(image);
show(image); } else {
} else { image.addEventListener("load", () => show(image));
image.addEventListener("load", () => { }
show(image); });
});
}
});
}, 50);
// Key shortcuts // Key shortcuts
window.addEventListener("keyup", (event) => { window.addEventListener("keyup", (event) => {

View file

@ -0,0 +1,37 @@
<?php
use Kirby\Api\Controller\Changes;
use Kirby\Cms\App;
use Kirby\Cms\Find;
return [
[
'pattern' => '(:all)/changes/discard',
'method' => 'POST',
'action' => function (string $path) {
return Changes::discard(
model: Find::parent($path),
);
}
],
[
'pattern' => '(:all)/changes/publish',
'method' => 'POST',
'action' => function (string $path) {
return Changes::publish(
model: Find::parent($path),
input: App::instance()->request()->get()
);
}
],
[
'pattern' => '(:all)/changes/save',
'method' => 'POST',
'action' => function (string $path) {
return Changes::save(
model: Find::parent($path),
input: App::instance()->request()->get()
);
}
],
];

View file

@ -0,0 +1,13 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\User;
use Kirby\Panel\Ui\Buttons\ViewButton;
return [
'user.theme' => function (App $kirby, User $user) {
if ($kirby->user()->is($user) === true) {
return new ViewButton(component: 'k-theme-view-button');
}
}
];

View file

@ -0,0 +1,14 @@
<?php
use Kirby\Cms\File;
use Kirby\Panel\Ui\Buttons\OpenButton;
use Kirby\Panel\Ui\Buttons\SettingsButton;
return [
'file.open' => function (File $file) {
return new OpenButton(link: $file->previewUrl());
},
'file.settings' => function (File $file) {
return new SettingsButton(model: $file);
}
];

View file

@ -0,0 +1,21 @@
<?php
use Kirby\Cms\Language;
use Kirby\Panel\Ui\Buttons\LanguageCreateButton;
use Kirby\Panel\Ui\Buttons\LanguageDeleteButton;
use Kirby\Panel\Ui\Buttons\LanguageSettingsButton;
use Kirby\Panel\Ui\Buttons\OpenButton;
return [
'languages.create' => fn () =>
new LanguageCreateButton(),
'language.open' => fn (Language $language) =>
new OpenButton(link: $language->url()),
'language.settings' => fn (Language $language) =>
new LanguageSettingsButton($language),
'language.delete' => function (Language $language) {
if ($language->isDeletable() === true) {
return new LanguageDeleteButton($language);
}
}
];

View file

@ -0,0 +1,72 @@
<?php
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Panel\Ui\Buttons\LanguagesDropdown;
use Kirby\Panel\Ui\Buttons\OpenButton;
use Kirby\Panel\Ui\Buttons\PageStatusButton;
use Kirby\Panel\Ui\Buttons\PreviewButton;
use Kirby\Panel\Ui\Buttons\SettingsButton;
use Kirby\Panel\Ui\Buttons\VersionsButton;
return [
'site.open' => function (Site $site, string $versionId = 'latest') {
$versionId = $versionId === 'compare' ? 'changes' : $versionId;
$link = $site->previewUrl($versionId);
if ($link !== null) {
return new OpenButton(
link: $link,
);
}
},
'site.preview' => function (Site $site) {
if ($site->previewUrl() !== null) {
return new PreviewButton(
link: $site->panel()->url(true) . '/preview/changes',
);
}
},
'site.versions' => function (Site $site, string $versionId = 'latest') {
return new VersionsButton(
model: $site,
versionId: $versionId
);
},
'page.open' => function (Page $page, string $versionId = 'latest') {
$versionId = $versionId === 'compare' ? 'changes' : $versionId;
$link = $page->previewUrl($versionId);
if ($link !== null) {
return new OpenButton(
link: $link,
);
}
},
'page.preview' => function (Page $page) {
if ($page->previewUrl() !== null) {
return new PreviewButton(
link: $page->panel()->url(true) . '/preview/changes',
);
}
},
'page.versions' => function (Page $page, string $versionId = 'latest') {
return new VersionsButton(
model: $page,
versionId: $versionId
);
},
'page.settings' => fn (Page $page) => new SettingsButton(model: $page),
'page.status' => fn (Page $page) => new PageStatusButton($page),
// `languages` button needs to be in site area,
// as the languages might be not loaded even in
// multilang mode when the `languages` option is deactivated
// (but content languages to switch between still can exist)
'languages' => fn (ModelWithContent $model) =>
new LanguagesDropdown($model),
// file buttons
...require __DIR__ . '/../files/buttons.php'
];

View file

@ -0,0 +1,20 @@
<?php
use Kirby\Cms\User;
use Kirby\Panel\Ui\Buttons\SettingsButton;
use Kirby\Panel\Ui\Buttons\ViewButton;
use Kirby\Toolkit\I18n;
return [
'users.create' => function (User $user, string|null $role = null) {
return new ViewButton(
dialog: 'users/create?role=' . $role,
disabled: $user->kirby()->roles()->canBeCreated()->count() < 1,
icon: 'add',
text: I18n::translate('user.create'),
);
},
'user.settings' => function (User $user) {
return new SettingsButton(model: $user);
}
];

View file

@ -0,0 +1,45 @@
<?php
use Kirby\Exception\Exception;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\I18n;
return [
'props' => [
/**
* Activates the batch delete option for the section
*/
'batch' => function (bool $batch = false) {
return $batch;
},
],
'methods' => [
'deleteSelected' => function (array $ids): bool {
if ($ids === []) {
return true;
}
// check if batch deletion is allowed
if ($this->batch() === false) {
throw new PermissionException(
message: 'The section does not support batch actions'
);
}
$min = $this->min();
// check if the section has enough items after the deletion
if ($this->total() - count($ids) < $min) {
throw new Exception(
message: I18n::template('error.section.' . $this->type() . '.min.' . I18n::form($min), [
'min' => $min,
'section' => $this->headline()
])
);
}
$this->models()->delete($ids);
return true;
}
]
];

View file

@ -0,0 +1,799 @@
{
"account.changeName": "Promijeni svoje ime",
"account.delete": "Obriši svoj račun",
"account.delete.confirm": "Da li stvarno želite obrisati svoj račun? Odmah ćete biti odjavljeni. Vaš račun neće biti moguće oporaviti.",
"activate": "Aktiviraj",
"add": "Dodaj",
"alpha": "Alpha",
"author": "Autor",
"avatar": "Profilna slika",
"back": "Natrag",
"cancel": "Odustani",
"change": "Promijeni",
"close": "Zatvori",
"changes": "Promjene",
"confirm": "Ok",
"collapse": "Skupi",
"collapse.all": "Skupi sve",
"color": "Boja",
"coordinates": "Koordinate",
"copy": "Kopiraj",
"copy.all": "Kopiraj sve",
"copy.success": "Kopirano",
"copy.success.multiple": "{count} kopirano!",
"copy.url": "Kopiraj URL",
"create": "Kreiraj",
"custom": "Prilagođeno",
"date": "Datum",
"date.select": "Odaberi datum",
"day": "Dan",
"days.fri": "Pet",
"days.mon": "Pon",
"days.sat": "Sub",
"days.sun": "Ned",
"days.thu": "Čet",
"days.tue": "Uto",
"days.wed": "Sri",
"debugging": "Debugiranje",
"delete": "Obriši",
"delete.all": "Obriši sve",
"dialog.fields.empty": "Ovaj dijalog nema polja",
"dialog.files.empty": "Nema datoteka za odabir",
"dialog.pages.empty": "Nema stranica za odabir",
"dialog.text.empty": "Ovaj dijalog ne definira nikakav tekst",
"dialog.users.empty": "Nema korisnika za odabir",
"dimensions": "Dimenzije",
"disable": "Onemogući",
"disabled": "Onemogućeno",
"discard": "Odbaci",
"drawer.fields.empty": "Ova ladica nema polja",
"domain": "Domena",
"download": "Download",
"duplicate": "Dupliciraj",
"edit": "Uredi",
"email": "Email",
"email.placeholder": "mail@example.com",
"enter": "Enter",
"entries": "Unosi",
"entry": "Unos",
"environment": "Okruženje",
"error": "Greška",
"error.access.code": "Nevažeći kod",
"error.access.login": "Nevažeći login",
"error.access.panel": "Pristup panelu nije dozvoljen",
"error.access.view": "Pristup ovom dijelu panela nije dozvoljen",
"error.avatar.create.fail": "Profilnu sliku nije moguće spremiti",
"error.avatar.delete.fail": "Profilna slika se ne može obrisati",
"error.avatar.dimensions.invalid": "Nevažeće dimenzije. Promijenite visinu i širinu profilne slike ispod 3000 piksela",
"error.avatar.mime.forbidden": "Profilna slika mora biti u JPEG ili PNG formatu",
"error.blueprint.notFound": "Blueprint \"{name}\" nije pronađen",
"error.blocks.max.plural": "Ne smijete dodati više od {max} blokova",
"error.blocks.max.singular": "Ne smijete dodati više od jednog bloka",
"error.blocks.min.plural": "Morate dodati najmanje {min} blokova",
"error.blocks.min.singular": "Morate dodati najmanje jedan blok",
"error.blocks.validation": "Postoji greška kod \"{field}\" polja, u bloku {index}, pri korištenju bloka tipa \"{fieldset}\" ",
"error.cache.type.invalid": "Nevažeći tip za cache \"{type}\"",
"error.content.lock.delete": "Ova verzija je zaključana i ne može se obrisati",
"error.content.lock.move": "Izvorna verzija je zaključana i ne može se premjestiti",
"error.content.lock.publish": "Ova verzija je već objavljena",
"error.content.lock.replace": "Ova verzija je zaključana i ne može se zamijeniti",
"error.content.lock.update": "Ova verzija je zaključana i ne može se urediti",
"error.entries.max.plural": "Ne smijete dodati više od {max} unosa",
"error.entries.max.singular": "Ne smijete dodati više od jednog unosa",
"error.entries.min.plural": "Morate dodati najmanje {min} unosa",
"error.entries.min.singular": "Morate dodati najmanje jedan unos",
"error.entries.supports": "\"{type}\" tip polja nije podržan za polje unosa",
"error.entries.validation": "Postoji greška na \"{field}\" polju u redu {index}",
"error.email.preset.notFound": "Email preset \"{name}\" nije pronađen",
"error.field.converter.invalid": "Nevažeći Converter \"{converter}\"",
"error.field.link.options": "Nevažeće opcije: {options}",
"error.field.type.missing": "Polje \"{name}\": Tip polja \"{type}\" ne postoji",
"error.file.changeName.empty": "Naziv ne smije biti prazan",
"error.file.changeName.permission": "Nemate dozvolu da promijenite naziv datoteke \"{filename}\"",
"error.file.changeTemplate.invalid": "Predložak za datoteku \"{id}\" se ne može promijeniti u \"{template}\" (dozvoljeno: \"{blueprints}\")",
"error.file.changeTemplate.permission": "Nemate dozvolu da promijenite predložak za datoteku \"{id}\"",
"error.file.delete.multiple": "Nije bilo moguće obrisati sve datoteke. Pokušajte ih obrisati pojedinačno da bi vidjeli specifičnu grešku koja sprječava brisanje. ",
"error.file.duplicate": "Datoteka sa imenom \"{filename}\" već postoji",
"error.file.extension.forbidden": "Ekstenzija \"{extension}\" nije dozvoljena",
"error.file.extension.invalid": "Nevažeća ekstenzija: {extension}",
"error.file.extension.missing": "Ekstenzija za datoteku \"{filename}\" nedostaje",
"error.file.maxheight": "Visina slike ne smije prelaziti {height} piksela",
"error.file.maxsize": "Datoteka je prevelika",
"error.file.maxwidth": "Širina slike ne smije prelaziti {width} piksela",
"error.file.mime.differs": "Uploadana datoteka mora biti istog mime tipa \"{mime}\"",
"error.file.mime.forbidden": "Tip medija \"{mime}\" nije dozvoljen",
"error.file.mime.invalid": "Nevažeći mime tip: {mime}",
"error.file.mime.missing": "Tip medija za \"{filename}\" se ne može očitati",
"error.file.minheight": "Visina slike mora biti najmanje {height} piksela",
"error.file.minsize": "Datoteka je premala",
"error.file.minwidth": "Širina slike mora biti najmanje {height} piksela",
"error.file.name.unique": "Naziv datoteke mora biti jedinstven",
"error.file.name.missing": "Naziv datoteke ne smije biti prazan",
"error.file.notFound": "Datoteka \"{filename}\" nije pronađena",
"error.file.orientation": "Orijentacija slike mora biti \"{orientation}\"",
"error.file.sort.permission": "Nemate dozvolu da promijenite sortiranje od \"{filename}\"",
"error.file.type.forbidden": "Nije dozvoljen upload {type} datoteka",
"error.file.type.invalid": "Nevažeći tip datoteke: {type}",
"error.file.undefined": "Datoteka nije pronađena",
"error.form.incomplete": "Popravi sve greške na formi...",
"error.form.notSaved": "Forma se ne može spremiti",
"error.language.code": "Unesi važeći kod za jezik",
"error.language.create.permission": "Nemate dozvolu da kreirate jezik",
"error.language.delete.permission": "Nemate dozvolu da obrišete jezik",
"error.language.duplicate": "Jezik već postoji",
"error.language.name": "Unesi važeći naziv za jezik",
"error.language.notFound": "Jezik nije pronađen",
"error.language.update.permission": "Nemate dozvolu da ažurirate jezik",
"error.layout.validation.block": "Postoji greška kod \"{field}\" polja, u bloku {blockIndex}, pri korištenju bloka tipa \"{fieldset}\" u rasporedu {layoutIndex}",
"error.layout.validation.settings": "Postoji greška u postavkama rasporeda {index}",
"error.license.domain": "Domena za licencu nedostaje",
"error.license.email": "Unesite važeću email adresu",
"error.license.format": "Unesite važeću licencu",
"error.license.verification": "Licenca se ne može verificirati",
"error.login.totp.confirm.invalid": "Nevažeći kod",
"error.login.totp.confirm.missing": "Unesi trenutni kod",
"error.object.validation": "Postoji greška u \"{label}\" polju:\n{message}",
"error.offline": "Panel je trenutno offline",
"error.page.changeSlug.permission": "Nemate dozvolu da promijenite URL nastavak za \"{slug}\"",
"error.page.changeSlug.reserved": "Putanja stranica najvišeg nivoa ne smije počinjati sa \"{path}\"",
"error.page.changeStatus.incomplete": "Stranica sadrži greške i ne može se objaviti",
"error.page.changeStatus.permission": "Status za ovu stranicu se ne može promijeniti",
"error.page.changeStatus.toDraft.invalid": "Stranica \"{slug}\" se ne može pretvoriti u skicu",
"error.page.changeTemplate.invalid": "Predložak za stranicu \"{slug}\" se ne može promijeniti",
"error.page.changeTemplate.permission": "Nemate dozvolu da promijenite predložak za \"{slug}\"",
"error.page.changeTitle.empty": "Naslov ne može biti prazan",
"error.page.changeTitle.permission": "Nemate dozvolu da promijenite naslov za \"{slug}\"",
"error.page.create.permission": "Nemate dozvolu da kreirate \"{slug}\"",
"error.page.delete": "Stranica \"{slug}\" se ne može obrisati",
"error.page.delete.confirm": "Unesite naslov stranice da potvrdite",
"error.page.delete.hasChildren": "Stranica sadrži podstranice i ne može se obrisati",
"error.page.delete.multiple": "Nije bilo moguće obrisati sve stranice. Pokušajte ih obrisati pojedinačno da bi vidjeli specifičnu grešku koja sprječava brisanje. ",
"error.page.delete.permission": "Nemate dozvolu da obrišete \"{slug}\"",
"error.page.draft.duplicate": "Skica stranice sa URL nastavkom \"{slug}\" već postoji",
"error.page.duplicate": "Stranica sa URL nastavkom \"{slug}\" već postoji",
"error.page.duplicate.permission": "Nemate dozvolu da duplicirate \"{slug}\"",
"error.page.move.ancestor": "Stranica se ne može premjestiti u samu sebe",
"error.page.move.directory": "Direktorij stranice se ne može premjestiti",
"error.page.move.duplicate": "Podstranica sa URL nastavkom \"{slug}\" već postoji",
"error.page.move.noSections": "Stranica \"{parent}\" ne može biti roditelj bilo koje podstranice jer ne sadrži sekciju \"pages\" u svom blueprintu",
"error.page.move.notFound": "Premještena stranica se ne može pronaći",
"error.page.move.permission": "Nemate dozvolu da premjestite \"{slug}\"",
"error.page.move.template": "Predložak \"{template}\" nije prihvaćen kao podstranica od \"{parent}\"",
"error.page.notFound": "Stranica \"{slug}\" nije pronađena",
"error.page.num.invalid": "Unesi važeći broj za sortiranje. Brojevi ne smiju biti negativni.",
"error.page.slug.invalid": "Unesi važeći URL prefiks",
"error.page.slug.maxlength": "Dužina URL nastavka mora biti manja od \"{length}\" znakova",
"error.page.sort.permission": "Stranica \"{slug}\" se ne može sortirati",
"error.page.status.invalid": "Odaberi važeći status stranice",
"error.page.undefined": "Stranica nije pronađena",
"error.page.update.permission": "Nemate dozvolu da uredite \"{slug}\"",
"error.section.files.max.plural": "Nije moguće dodati više od {max} datoteka u \"{section}\" sekciju",
"error.section.files.max.singular": "Nije moguće dodati više od jedne datoteke u \"{section}\" sekciju",
"error.section.files.min.plural": "Sekcija \"{section}\" zahtjeva najmanje {min} datoteka",
"error.section.files.min.singular": "Sekcija \"{section}\" zahtjeva najmanje jednu datoteku",
"error.section.pages.max.plural": "Nije moguće dodati više od {max} stranica u \"{section}\" sekciju",
"error.section.pages.max.singular": "Nije moguće dodati više od jedne stranice \"{section}\" sekciju",
"error.section.pages.min.plural": "Sekcija \"{section}\" zahtjeva najmanje {min} stranica",
"error.section.pages.min.singular": "Sekcija \"{section}\" zahtjeva najmanje jednu stranicu",
"error.section.notLoaded": "Sekcija \"{name}\" se nije mogla učitati",
"error.section.type.invalid": "Tip sekcije \"{type}\" nije važeći",
"error.site.changeTitle.empty": "The title must not be empty",
"error.site.changeTitle.permission": "Nemate dozvolu da promijenite naslov stranice",
"error.site.update.permission": "Nemate dozvolu da uredite stranicu",
"error.structure.validation": "Postoji greška na \"{field}\" polju u redu {index}",
"error.template.default.notFound": "Default predložak ne postoji",
"error.unexpected": "Dogodila se neočekivana greška! Omogućite debug modus za više informacija: https://getkirby.com/docs/reference/system/options/debug",
"error.user.changeEmail.permission": "Nemate dozvolu da promijenite email adresu korisnika \"{name}\"",
"error.user.changeLanguage.permission": "Nemate dozvolu da promijenite jezik korisnika \"{name}\"",
"error.user.changeName.permission": "Nemate dozvolu da promijenite ime korisnika \"{name}\"",
"error.user.changePassword.permission": "Nemate dozvolu da promijenite šifru korisnika \"{name}\"",
"error.user.changeRole.lastAdmin": "Uloga zadnjeg administratora se ne može promijeniti",
"error.user.changeRole.permission": "Nemate dozvolu da promijenite ulogu korisnika \"{name}\"",
"error.user.changeRole.toAdmin": "Nemate dozvolu da promijenite ulogu nekog korisnika u administratora",
"error.user.create.permission": "Nemate dozvolu da kreirate ovog korisnika",
"error.user.delete": "Korisnik \"{name}\" se ne može obrisati",
"error.user.delete.lastAdmin": "Zadnji administrator se ne može obrisati",
"error.user.delete.lastUser": "Zadnji korisnik se ne može obrisati",
"error.user.delete.permission": "Nemate dozvolu da obrišete korisnika \"{name}\"",
"error.user.duplicate": "Korisnik sa email adresom \"{email}\" već postoji",
"error.user.email.invalid": "Unesite važeću email adresu",
"error.user.language.invalid": "Unesi važeći jezik",
"error.user.notFound": "Korisnik \"{name}\" nije pronađen",
"error.user.password.excessive": "Unesite važeću šifru. Šifre ne smiju biti duže od 1000 znakova",
"error.user.password.invalid": "Unesi važeću šifru. Šifre moraju sadržavati najmanje 8 znakova.",
"error.user.password.notSame": "Šifre se ne podudaraju",
"error.user.password.undefined": "Korisnik nema šifru",
"error.user.password.wrong": "Kriva šifra",
"error.user.role.invalid": "Uloga je nevažeća",
"error.user.undefined": "Nije moguće pronaći korisnika",
"error.user.update.permission": "Nemate dozvolu da uredite korisnika \"{name}\"",
"error.validation.accepted": "Potvrdi",
"error.validation.alpha": "Koristi samo znakove u rasponu a-z",
"error.validation.alphanum": "Koristi samo znakove u rasponu a-z ili brojeve 0-9",
"error.validation.anchor": "Unesite ispravan anker linka",
"error.validation.between": "Unesi vrijednost između \"{min}\" i \"{max}\"",
"error.validation.boolean": "Potvrdi ili otkaži",
"error.validation.color": "Unesite važeću boju u {format} formatu",
"error.validation.contains": "Unesi vrijednost koja sadrži \"{needle}\"",
"error.validation.date": "Unesi važeći datum",
"error.validation.date.after": "Unesi datum nakon {date}",
"error.validation.date.before": "Unesi datum prije {date}",
"error.validation.date.between": "Unesi datum između {min} i {max}",
"error.validation.denied": "Otkaži",
"error.validation.different": "Vrijednost ne može biti \"{other}\"",
"error.validation.email": "Unesite važeću email adresu",
"error.validation.endswith": "Vrijednost mora završavati sa \"{end}\"",
"error.validation.filename": "Unesi važeći naziv datoteke",
"error.validation.in": "Unesi jedno od slijedećeg: ({in})",
"error.validation.integer": "Unesi važeći cijeli broj",
"error.validation.ip": "Unesi važeću IP adresu",
"error.validation.less": "Unesi vrijednost manju od {max}",
"error.validation.linkType": "Tip linka nije dozvoljen",
"error.validation.match": "Vrijednost ne odgovara očekivanom uzorku",
"error.validation.max": "Unesi vrijednost jednaku ili manju od {max}",
"error.validation.maxlength": "Unesi kraću vrijednost. (max. {max} znakova)",
"error.validation.maxwords": "Unesi najviše {max} riječ(i)",
"error.validation.min": "Unesi vrijednost jednaku ili veću od {min}",
"error.validation.minlength": "Unesi dužu vrijednost. (min. {min} znakova)",
"error.validation.minwords": "Unesi najmanje {min} riječ(i)",
"error.validation.more": "Unesi veću vrijednost od {min}",
"error.validation.notcontains": "Unesi vrijednost koja NE sadrži \"{needle}\"",
"error.validation.notin": "Nemojte unositi bilo šta od slijedećeg: ({notIn})",
"error.validation.option": "Odaberite važeću opciju",
"error.validation.num": "Odaberite važeći broj",
"error.validation.required": "Unesi nešto",
"error.validation.same": "Unesi \"{other}\"",
"error.validation.size": "Dužina unosa mora biti \"{size}\"",
"error.validation.startswith": "Unos mora počinjati sa \"{start}\"",
"error.validation.tel": "Unesite neformatirani broj telefona",
"error.validation.time": "Unesi važeće vrijeme",
"error.validation.time.after": "Unesite vrijeme poslije {time}",
"error.validation.time.before": "Unesite vrijeme prije {time}",
"error.validation.time.between": "Unesite vrijeme između {min} i {max}",
"error.validation.uuid": "Unesite važeći UUID",
"error.validation.url": "Unesi važeći URL",
"expand": "Proširi",
"expand.all": "Proširi sve",
"field.invalid": "Polje je nevažeće",
"field.required": "Polje je obavezno",
"field.blocks.changeType": "Promijena tipa",
"field.blocks.code.name": "Kod",
"field.blocks.code.language": "Jezik",
"field.blocks.code.placeholder": "Vaš kod ...",
"field.blocks.delete.confirm": "Da li stvarno želite obrisati ovaj blok?",
"field.blocks.delete.confirm.all": "Da li stvarno želite obrisati sve blokove?",
"field.blocks.delete.confirm.selected": "Da li stvarno želite obrisati odabrane blokove?",
"field.blocks.empty": "Još nema blokova",
"field.blocks.fieldsets.empty": "Još nema skupova polja",
"field.blocks.fieldsets.label": "Odaberite tip bloka ...",
"field.blocks.fieldsets.paste": "Pritisnite <kbd>{{ shortcut }}</kbd> da uvezete rasporede/blokove iz vašeg međuspremnika.<small>Samo oni dozvoljeni u trenutnom polju će biti umetnuti.</small>",
"field.blocks.gallery.name": "Galerija",
"field.blocks.gallery.images.empty": "Još nema slika",
"field.blocks.gallery.images.label": "Slike",
"field.blocks.heading.level": "Nivo",
"field.blocks.heading.name": "Naslov",
"field.blocks.heading.text": "Tekst",
"field.blocks.heading.placeholder": "Naslov ...",
"field.blocks.figure.back.plain": "Obično",
"field.blocks.figure.back.pattern.light": "Uzorak (svijetlo)",
"field.blocks.figure.back.pattern.dark": "Uzorak (tamno)",
"field.blocks.image.alt": "Alternativni tekst",
"field.blocks.image.caption": "Natpis",
"field.blocks.image.crop": "Odreži",
"field.blocks.image.link": "Link",
"field.blocks.image.location": "Lokacija",
"field.blocks.image.location.internal": "Ova web stranica",
"field.blocks.image.location.external": "Eksterni izvor",
"field.blocks.image.name": "Slika",
"field.blocks.image.placeholder": "Odaberi sliku",
"field.blocks.image.ratio": "Omjer",
"field.blocks.image.url": "URL slike",
"field.blocks.line.name": "Linija",
"field.blocks.list.name": "Lista",
"field.blocks.markdown.name": "Markdown",
"field.blocks.markdown.label": "Tekst",
"field.blocks.markdown.placeholder": "Markdown ...",
"field.blocks.quote.name": "Citat",
"field.blocks.quote.text.label": "Tekst",
"field.blocks.quote.text.placeholder": "Citat ...",
"field.blocks.quote.citation.label": "Citiranje",
"field.blocks.quote.citation.placeholder": "po ...",
"field.blocks.text.name": "Tekst",
"field.blocks.text.placeholder": "Tekst ...",
"field.blocks.video.autoplay": "Automatski reproduciraj",
"field.blocks.video.caption": "Natpis",
"field.blocks.video.controls": "Kontrole",
"field.blocks.video.location": "Lokacija",
"field.blocks.video.loop": "Ponavljaj",
"field.blocks.video.muted": "Utišano",
"field.blocks.video.name": "Video",
"field.blocks.video.placeholder": "Unesi video URL",
"field.blocks.video.poster": "Poster",
"field.blocks.video.preload": "Predučitavanje",
"field.blocks.video.url.label": "Video-URL",
"field.blocks.video.url.placeholder": "https://youtube.com/?v=",
"field.entries.delete.confirm.all": "Da li stvarno želite obrisati sve unose?",
"field.entries.empty": "Još nema unosa",
"field.files.empty": "Niti jedna datoteka još nije odabrana",
"field.files.empty.single": "Datoteka još nije odabrana",
"field.layout.change": "Promijeni raspored",
"field.layout.delete": "Obriši raspored",
"field.layout.delete.confirm": "Da li stvarno želite obrisati ovaj raspored?",
"field.layout.delete.confirm.all": "Da li stvarno želite obrisati sve rasporede?",
"field.layout.empty": "Još nema redova",
"field.layout.select": "Odaberi raspored",
"field.object.empty": "Još nema informacija",
"field.pages.empty": "Niti jedna stranica još nije odabrana",
"field.pages.empty.single": "Stranica još nije odabrana",
"field.structure.delete.confirm": "Da li stvarno želite obrisati ovaj red?",
"field.structure.delete.confirm.all": "Da li stvarno želite obrisati sve unose?",
"field.structure.empty": "Još nema unosa",
"field.users.empty": "Niti jedan korisnik još nije odabran",
"field.users.empty.single": "Korisnik još nije odabran",
"fields.empty": "Još nema polja",
"file": "Datoteka",
"file.blueprint": "Ovaj fajl još uvijek nema blueprint. Možete definirati postavke u <strong>/site/blueprints/files/{blueprint}.yml</strong>",
"file.changeTemplate": "Promijeni predložak",
"file.changeTemplate.notice": "Promjena predloška datoteke će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Ako novi predložak definira određena pravila, npr. dimenzije slike, ona će se također nepovratno primijeniti. Koristite s oprezom.",
"file.delete.confirm": "Da li stvarno želite obrisati <br><strong>{filename}</strong>?",
"file.focus.placeholder": "Postavi fokalnu tačku",
"file.focus.reset": "Ukloni fokalnu tačku",
"file.focus.title": "Fokus",
"file.sort": "Promijeni poziciju",
"files": "Datoteke",
"files.delete.confirm.selected": "Da li stvarno želite da obrišete odabrane datoteke? Ova akcija se ne može poništiti.",
"files.empty": "Još nema datoteka...",
"filter": "Filter",
"form.discard": "Odbaci promjene",
"form.discard.confirm": "Da li stvarno želite <strong>odbaciti sve promjene</strong>?",
"form.locked": "Ovaj sadržaj je vama onemogućen jer ga trenutno uređuje drugi korisnik",
"form.unsaved": "Trenutne promjene još uvijek nisu spremljene",
"form.preview": "Pregledaj izmjene",
"form.preview.draft": "Pregledaj skicu",
"hide": "Sakrij",
"hour": "Sat",
"hue": "Nijansa",
"import": "Uvoz",
"info": "Info",
"insert": "Umetni",
"insert.after": "Umetni nakon",
"insert.before": "Umetni prije",
"install": "Instaliraj",
"installation": "Instalacija",
"installation.completed": "Panel je instaliran",
"installation.disabled": "Instaler za panel je onemogućen na javnim serverima po defaultu. Pokreni instaler na lokalnom okruženju ili omogući sa <code>panel.install</code> opcijom.",
"installation.issues.accounts": "Folder <code>/site/accounts</code> ne postoji ili nema dozvolu pisanja",
"installation.issues.content": "Folder <code>/content</code> ne postoji ili nema dozvolu pisanja",
"installation.issues.curl": "Ekstenzija <code>CURL</code> je neophodna",
"installation.issues.headline": "Panel se ne može instalirati",
"installation.issues.mbstring": "Ekstenzija <code>MB String</code> je neophodna",
"installation.issues.media": "Folder <code>/media</code> ne postoji ili nema dozvolu pisanja",
"installation.issues.php": "Obavezno koristite <code>PHP 7+</code>",
"installation.issues.sessions": "Folder <code>/site/sessions</code> ne postoji ili nema dozvolu pisanja",
"language": "Jezik",
"language.code": "Kod",
"language.convert": "Postavi kao zadani",
"language.convert.confirm": "<p>Da li stvarno želite konvertirati <strong>{name}</strong> u zadani jezik? Ova akcija se ne može poništiti. </p><p>Ukoliko <strong>{name}</strong> sadrži dijelove bez prijevoda, neće postojati važeći fallback što može prouzrokovati prazne dijelove na ovoj stranici.</p>",
"language.create": "Dodaj novi jezik",
"language.default": "Zadani jezik",
"language.delete.confirm": "Da li stvarno želite obrisati jezik <strong>{name}</strong> including all translations? This cannot be undone!",
"language.deleted": "Jezik je obrisan",
"language.direction": "Smjer čitanja",
"language.direction.ltr": "Sa lijeva na desno",
"language.direction.rtl": "Sa desna na lijevo",
"language.locale": "PHP lokalizacijski string",
"language.locale.warning": "Sistem koristi prilagođenu lokalizaciju. Promijeni postavke u jezičnoj datoteci u /site/languages",
"language.name": "Naziv",
"language.secondary": "Sekundarni jezik",
"language.settings": "Postavke jezika",
"language.updated": "Jezik je izmijenjen",
"language.variables": "Jezične varijable",
"language.variables.empty": "Još nema prijevoda",
"language.variable.delete.confirm": "Da li stvarno želite da obrišete varijablu za {key}?",
"language.variable.entries": "Values",
"language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts <code>0</code>, <code>1</code>, <code>2 and more</code>. Use the <code>{count}</code> placeholder to insert the actual count.",
"language.variable.key": "Ključ",
"language.variable.multiple": "Countable?",
"language.variable.multiple.text": "Use different translation strings",
"language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Varijablu nije moguće pronaći",
"language.variable.value": "Vrijednost",
"languages": "Jezici",
"languages.default": "Zadani jezik",
"languages.empty": "Jezici još nisu definirani",
"languages.secondary": "Sekundarni jezici",
"languages.secondary.empty": "Sekundarni jezici još nisu definirani",
"license": "Licenca",
"license.activate": "Aktivirajte odmah",
"license.activate.label": "Aktivirajte vašu licencu",
"license.activate.domain": "Vaša licenca će biti aktivirana za <strong>{host}</strong>.",
"license.activate.local": "Upravo ćete aktivirati svoju Kirby licencu za vašu lokalnu domenu {host}. Ako će ova stranica biti postavljena na javnu domenu, molimo vas da je tamo aktivirate. Ako je {host} domena za koju želite koristiti svoju licencu, molimo vas da nastavite.",
"license.activated": "Aktivirano",
"license.buy": "Kupite licencu",
"license.code": "Kod",
"license.code.help": "Primili ste vašu licencu nakon kupovine putem e-maila. Molimo da je kopirate i zalijepite ovdje.",
"license.code.label": "Unesite vašu licencu",
"license.status.active.info": "Uključuje nove glavne verzije do {date}",
"license.status.active.label": "Važeća licenca",
"license.status.demo.info": "Ovo je demo instalacija",
"license.status.demo.label": "Demo",
"license.status.inactive.info": "Obnovi licencu da bi se ažuriralo na nove glavne verzije",
"license.status.inactive.label": "Nema novih glavnih verzija",
"license.status.legacy.bubble": "Spremni da obnovite vašu licencu?",
"license.status.legacy.info": "Vaša licenca ne pokriva ovu verziju",
"license.status.legacy.label": "Obnovite vašu licencu",
"license.status.missing.bubble": "Spremni da objavite vašu stranicu?",
"license.status.missing.info": "Ne postoji važeća licenca",
"license.status.missing.label": "Aktivirajte vašu licencu",
"license.status.unknown.info": "Status licence je nepoznat",
"license.status.unknown.label": "Nepoznato",
"license.manage": "Upravljanje vašim licencama",
"license.purchased": "Kupljeno",
"license.success": "Hvala što podržavate Kirby",
"license.unregistered.label": "Neregistrovano",
"link": "Link",
"link.text": "Tekst linka",
"loading": "Učitavanje",
"lock.unsaved": "Izmjene nisu spremljene",
"lock.unsaved.empty": "Ne postoje druge izmjene za spremanje",
"lock.unsaved.files": "Nespremljene datoteke",
"lock.unsaved.pages": "Nespremljene stranice",
"lock.unsaved.users": "Nespremljeni računi",
"lock.isLocked": "Izmjene od strane <strong>{email}</strong>",
"lock.unlock": "Otključaj",
"lock.unlock.submit": "Otključaj i prepiši preko nespremljenih promjena od <strong>{email}</strong>",
"lock.isUnlocked": "Vaše izmjene su zamijenjene sa izmjenama drugog korisnika. Dostupne su za download, te ih možete spojiti manuelno.",
"login": "Prijava",
"login.code.label.login": "Kod za prijavu",
"login.code.label.password-reset": "Kod za resetiranje šifre",
"login.code.placeholder.email": "000 000",
"login.code.placeholder.totp": "000000",
"login.code.text.email": "Ako je vaša e-mail adresa registrirana, traženi kod je poslan putem e-maila.",
"login.code.text.totp": "Unesite jednokratni kod iz vaše autentifikacijske aplikacije.",
"login.email.login.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za prijavu na Panel stranice {site}.\nSljedeći kod za prijavu važiće {timeout} minuta:\n\n{code}\n\nAko niste zatražili kod za prijavu, molimo vas da zanemarite ovaj e-mail ili kontaktirate svog administratora ako imate pitanja.\nIz sigurnosnih razloga, molimo vas da NE prosljeđujete ovaj e-mail.",
"login.email.login.subject": "Vaš kod za prijavu",
"login.email.password-reset.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za resetiranje šifre za Panel stranice {site}.\nSljedeći kod za resetiranje šifre važiće {timeout} minuta:\n\n{code}\n\nAko niste zatražili kod za resetiranje šifre, molimo vas da zanemarite ovaj e-mail ili kontaktirate svog administratora ako imate pitanja.\nIz sigurnosnih razloga, molimo vas da NE prosljeđujete ovaj e-mail.",
"login.email.password-reset.subject": "Kod za resetiranje šifre",
"login.remember": "Zapamti moju prijavu",
"login.reset": "Resetiraj šifru",
"login.toggleText.code.email": "Prijava putem emaila",
"login.toggleText.code.email-password": "Prijava sa šifrom",
"login.toggleText.password-reset.email": "Zaboravljena šifra?",
"login.toggleText.password-reset.email-password": "← Nazad na login",
"login.totp.enable.option": "Postavite jednokratne kodove",
"login.totp.enable.intro": "Autentifikacijske aplikacije mogu generirati jednokratne kodove koji se koriste kao drugi faktor prilikom prijavljivanja u vaš račun.",
"login.totp.enable.qr.label": "1. Skeniraj ovaj QR kod",
"login.totp.enable.qr.help": "Skeniranje nije moguće? Dodaj ključ za postavljanje <code>{secret}</code> ručno u aplikaciju za autentifikaciju.",
"login.totp.enable.confirm.headline": "2. Potvrdi sa generisanim kodom",
"login.totp.enable.confirm.text": "Vaša aplikacija generira novi jednokratni kod svakih 30 sekundi. Unesite trenutni kod da završite postavljanje:",
"login.totp.enable.confirm.label": "Trenutni kod",
"login.totp.enable.confirm.help": "Nakon ovih postavki, tražićemo od vas jednokratni kod prilikom svakog prijavljivanja.",
"login.totp.enable.success": "Jednokratni kodovi omogućeni",
"login.totp.disable.option": "Onemogući jednokratne kodove",
"login.totp.disable.label": "Unesite vaš password da bi onemogućili jednokratne kodove",
"login.totp.disable.help": "Ubuduće, drugačiji drugi faktor poput koda za prijavu dostavljenog putem e-maila će biti zatražen prilikom slijedeće prijave. Uvijek ponovo možete postaviti jednokratne kodove kasnije.",
"login.totp.disable.admin": "<p>Ovo će onemogućiti jednokratne kodove za <strong>{user}</strong>.</p><p>Ubuduće, drugačiji drugi faktor poput koda za prijavu dostavljenog putem e-maila će biti zatražen prilikom prijave. {user} može postaviti jednokratne kodove nakon njihove slijedeće prijave.</p>",
"login.totp.disable.success": "Jednokratni kodovi onemogućeni",
"logout": "Odjava",
"merge": "Spoji",
"menu": "Izbornik",
"meridiem": "AM/PM",
"mime": "Tip medija",
"minutes": "Minute",
"month": "Mjesec",
"months.april": "April",
"months.august": "August",
"months.december": "Decembar",
"months.february": "Feburar",
"months.january": "Januar",
"months.july": "Juli",
"months.june": "Juni",
"months.march": "Mart",
"months.may": "Maj",
"months.november": "Novembar",
"months.october": "Oktobar",
"months.september": "Septembar",
"more": "Više",
"move": "Pomjeri",
"name": "Ime",
"next": "Dalje",
"night": "Noć",
"no": "ne",
"off": "off",
"on": "on",
"open": "Otvori",
"open.newWindow": "Otvori u novom prozoru",
"option": "Opcija",
"options": "Opcije",
"options.none": "Nema opcija",
"options.all": "Prikaži svih {count} options",
"orientation": "Orijentacija",
"orientation.landscape": "Pejzaž",
"orientation.portrait": "Portret",
"orientation.square": "Kvadrat",
"page": "Strana",
"page.blueprint": "Ova stranica još uvijek nema blueprint. Možete definirati postavke u <strong>/site/blueprints/pages/{blueprint}.yml</strong>",
"page.changeSlug": "Promijeni URL",
"page.changeSlug.fromTitle": "Kreiraj iz naslova",
"page.changeStatus": "Promijeni status",
"page.changeStatus.position": "Odaberi poziciju",
"page.changeStatus.select": "Odaberi novi status",
"page.changeTemplate": "Promijeni predložak",
"page.changeTemplate.notice": "Promjena predloška stranice će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Koristite s oprezom.",
"page.create": "Kreiraj kao {status}",
"page.delete.confirm": "Da li stvarno želite obrisati <strong>{title}</strong>?",
"page.delete.confirm.subpages": "<strong>Ova stranica ima podstranice</strong>. <br>Sve podstranice će također biti obrisane.",
"page.delete.confirm.title": "Napiši naslov stranice kao potvrdu ove akcije",
"page.duplicate.appendix": "Kopiraj",
"page.duplicate.files": "Kopiraj datoteke",
"page.duplicate.pages": "Kopiraj stranice",
"page.move": "Premjesti stranicu",
"page.sort": "Promijeni poziciju",
"page.status": "Status",
"page.status.draft": "Skica",
"page.status.draft.description": "Stranica je u izradi, te je vidljiva jedino prijavljenim urednicima",
"page.status.listed": "Javno",
"page.status.listed.description": "Stranica je javno dostupna",
"page.status.unlisted": "Neizlistano",
"page.status.unlisted.description": "Stranica je dostupna putem direktnog URL-a",
"pages": "Stranice",
"pages.delete.confirm.selected": "Da li stvarno želite da obrišete odabrane stranice? Ova akcija se ne može poništiti.",
"pages.empty": "Još nema stranica...",
"pages.status.draft": "Skica",
"pages.status.listed": "Javno",
"pages.status.unlisted": "Neizlistano",
"pagination.page": "Strana",
"password": "Šifra",
"paste": "Zalijepi",
"paste.after": "Zalijepi nakon",
"paste.success": "{count} zalijepljeno!",
"pixel": "Piksel",
"plugin": "Plugin",
"plugins": "Plugini",
"prev": "Previous",
"preview": "Pregled",
"publish": "Objavi",
"published": "Javno",
"remove": "Remove",
"rename": "Preimenuj",
"renew": "Obnovi",
"replace": "Zamijeni",
"replace.with": "Zamijeni sa",
"retry": "Pokušaj ponovo",
"revert": "Revert",
"revert.confirm": "Da li stvarno želite <strong>obrisati sve nespremljene promjene</strong>?",
"role": "Uloga",
"role.admin.description": "Administrator ima sva prava",
"role.admin.title": "Administrator",
"role.all": "Sve",
"role.empty": "Za ovu ulogu ne postoje korisnici",
"role.description.placeholder": "Bez opisa",
"role.nobody.description": "Ovo je pomoćna uloga bez ikakvih prava",
"role.nobody.title": "Niko",
"save": "Spremi",
"saved": "Spremljeno",
"search": "Traži",
"searching": "Traženje",
"search.min": "Unesi {min} znakova za pretraživanje",
"search.all": "Prikaži svih {count} rezultata",
"search.results.none": "Nema rezultata",
"section.invalid": "Ova sekcija je nevažeća",
"section.required": "Ova sekcija je potrebna",
"security": "Sigurnost",
"select": "Odaberi",
"server": "Server",
"settings": "Postavke",
"show": "Prikaži",
"site.blueprint": "Stranica još uvijek nema blueprint. Možete definirati postavke u <strong>/site/blueprints/site.yml</strong>",
"size": "Veličina",
"slug": "URL nastavak",
"sort": "Sortiranje",
"sort.drag": "Prevuci za sortiranje ...",
"split": "Podijeli",
"stats.empty": "Nema izvještaja",
"status": "Status",
"system.info.copy": "Kopiraj info",
"system.info.copied": "Sistemske info kopirane",
"system.issues.content": "Čini se da je content folder izložen",
"system.issues.eol.kirby": "Vaša instalirana Kirby verzija je dostigla end-of-life i neće primati daljnja sigurnosna ažuriranja",
"system.issues.eol.plugin": "Vaša instalirana verzija plugina { plugin } je dostigla end-of-life i neće primati daljnja sigurnosna ažuriranja",
"system.issues.eol.php": "Vaša instalirana PHP verzija { release } je dostigla end-of-life i neće primati daljnja sigurnosna ažuriranja",
"system.issues.debug": "Debugiranje se mora isključiti u produkciji",
"system.issues.git": "Čini se da je .git folder izložen",
"system.issues.https": "Preporučujemo HTTPS za sve vaše stranice",
"system.issues.kirby": "Čini se da je kirby folder izložen",
"system.issues.local": "Stranica radi lokalno uz opuštene signurnosne provjere",
"system.issues.site": "Čini se da je site folder izložen",
"system.issues.vue.compiler": "Vue template compiler je omogućen",
"system.issues.vulnerability.kirby": "Vaša instalacija je možda pogođena slijedećim sigurnosnim propustom ({ severity } stepen): { description }",
"system.issues.vulnerability.plugin": "Vaša instalacija je možda pogođena slijedećim sigurnosnim propustom u pluginu {plugin} ({ severity } stepen): { description }",
"system.updateStatus": "Ažuriraj status",
"system.updateStatus.error": "Nije moguće provjeriti za ažuriranja",
"system.updateStatus.not-vulnerable": "Nema poznatih sigurnosnih propusta",
"system.updateStatus.security-update": "Besplatno sigurnosno ažiriranje { version } dostupno",
"system.updateStatus.security-upgrade": "Nadogradnja { version } sa sigurnosnim popravkama dostupna",
"system.updateStatus.unreleased": "Neobjavljena verzija",
"system.updateStatus.up-to-date": "Ažurirano",
"system.updateStatus.update": "Besplatno ažuriranje { version } dostupno",
"system.updateStatus.upgrade": "Nadogradnja { version } dostupna",
"tel": "Telefon",
"tel.placeholder": "+38761222333",
"template": "Predložak",
"theme": "Tema",
"theme.light": "Svjetla upaljena",
"theme.dark": "Svjetla ugašena",
"theme.automatic": "Uskladi sa sistemskim postavkama",
"title": "Naslov",
"today": "Danas",
"toolbar.button.clear": "Očisti formatiranje",
"toolbar.button.code": "Kod",
"toolbar.button.bold": "Podebljano",
"toolbar.button.email": "Email",
"toolbar.button.headings": "Naslovi",
"toolbar.button.heading.1": "Naslov 1",
"toolbar.button.heading.2": "Naslov 2",
"toolbar.button.heading.3": "Naslov 3",
"toolbar.button.heading.4": "Naslov 4",
"toolbar.button.heading.5": "Naslov 5",
"toolbar.button.heading.6": "Naslov 6",
"toolbar.button.italic": "Kurziv",
"toolbar.button.file": "Datoteka",
"toolbar.button.file.select": "Odaberi datoteku",
"toolbar.button.file.upload": "Uploadaj datoteku",
"toolbar.button.link": "Link",
"toolbar.button.paragraph": "Paragraf",
"toolbar.button.strike": "Precrtano",
"toolbar.button.sub": "Podpis",
"toolbar.button.sup": "Nadpis",
"toolbar.button.ol": "Uređena list",
"toolbar.button.underline": "Podvučeno",
"toolbar.button.ul": "Označena lista",
"translation.author": "Faris Mujakić",
"translation.direction": "ltr",
"translation.name": "Bosanski",
"translation.locale": "bs_BA",
"type": "Tip",
"upload": "Uploadaj",
"upload.error.cantMove": "Uploadana datoteka se ne može premjestiti",
"upload.error.cantWrite": "Greška prilikom pisanja datoteke na disk",
"upload.error.default": "Datoteka se ne može uploadati",
"upload.error.extension": "Upload zaustavljen od strane ekstenzije",
"upload.error.formSize": "Uploadana datoteka premašuje MAX_FILE_SIZE direktivu navedenu u formi",
"upload.error.iniPostSize": "Uploadana datoteka premašuje post_max_size direktivu u php.ini",
"upload.error.iniSize": "Uploadana datoteka premašuje upload_max_filesize direktivu u php.ini",
"upload.error.noFile": "Datoteka nije uploadana",
"upload.error.noFiles": "Datoteke nisu uploadane",
"upload.error.partial": "Datoteka je djelimično uploadana",
"upload.error.tmpDir": "Nedostaje privremeni folder",
"upload.errors": "Greška",
"upload.progress": "Slanje...",
"url": "Url",
"url.placeholder": "https://example.com",
"user": "Korisnik",
"user.blueprint": "Možete definirati dodatne sekcije i polja forme za ovu ulogu korisnika u <strong>/site/blueprints/users/{role}.yml</strong>",
"user.changeEmail": "Promijeni email",
"user.changeLanguage": "Promijeni jezik",
"user.changeName": "Preimenuj ovog korisnika",
"user.changePassword": "Promijeni šifru",
"user.changePassword.current": "Trenutna šifra",
"user.changePassword.new": "Nova šifra",
"user.changePassword.new.confirm": "Potvrdi novu šifru...",
"user.changeRole": "Promijeni ulogu",
"user.changeRole.select": "Odaberi novu ulogu",
"user.create": "Dodaj novog korisnika",
"user.delete": "Obriši ovog korisnika",
"user.delete.confirm": "Da li stvarno želite obrisati <br><strong>{email}</strong>?",
"users": "Korisnici",
"version": "Verzija",
"version.changes": "Promijenjena verzija",
"version.compare": "Uporedi verzije",
"version.current": "Trenutna verzija",
"version.latest": "Zadnja verzija",
"versionInformation": "Informacije o verziji",
"view": "Pregled",
"view.account": "Tvoj račun",
"view.installation": "Instalacija",
"view.languages": "Jezici",
"view.resetPassword": "Resetiraj šifru",
"view.site": "Stranica",
"view.system": "Sistem",
"view.users": "Korisnici",
"welcome": "Dobrodošli",
"year": "Godina",
"yes": "da"
}

View file

@ -0,0 +1,799 @@
{
"account.changeName": "Promenite vaše ime",
"account.delete": "Izbrišite vaš nalog",
"account.delete.confirm": "Da li zaista želite da izbrišete vaš nalog? Bićete odjavljeni odmah, i vaš nalog ne može biti povraćen.",
"activate": "Aktivirati",
"add": "Dodaj",
"alpha": "Alfa",
"author": "Autor",
"avatar": "Profilna slika",
"back": "Nazad",
"cancel": "Otkažite",
"change": "Promenite",
"close": "Zatvorite",
"changes": "Promene",
"confirm": "Ok",
"collapse": "Skupi",
"collapse.all": "Skupi sve",
"color": "Boja",
"coordinates": "Koordinate",
"copy": "Kopiraj",
"copy.all": "Kopiraj sve",
"copy.success": "Copied",
"copy.success.multiple": "{count} kopirano!",
"copy.url": "Copy URL",
"create": "Kreiraj",
"custom": "Običaj",
"date": "Datum",
"date.select": "Izaberite datum",
"day": "Dan",
"days.fri": "Pet",
"days.mon": "Pon",
"days.sat": "Sub",
"days.sun": "Ned",
"days.thu": "Čet",
"days.tue": "Uto",
"days.wed": "Sre",
"debugging": "Otklanjanje grešaka",
"delete": "Obriši",
"delete.all": "Obriši sve",
"dialog.fields.empty": "Ovaj dijalog nema polja",
"dialog.files.empty": "Nema fajlova koji se mogu izabrati",
"dialog.pages.empty": "Nema stranica koje se mogu izabrati",
"dialog.text.empty": "Ovaj dijalog ne definiše nikakav tekst",
"dialog.users.empty": "Nema korisnika koji se mogu izabrati",
"dimensions": "Dimenzije",
"disable": "Onemogućiti",
"disabled": "Onemogućeno",
"discard": "Odbaci",
"drawer.fields.empty": "Ova fioka nema polja",
"domain": "Domen",
"download": "Preuzmi",
"duplicate": "Kopiraj",
"edit": "Uredi",
"email": "Email",
"email.placeholder": "mail@example.com",
"enter": "Uneti",
"entries": "Unosi",
"entry": "Unos",
"environment": "Okruženje",
"error": "Greška",
"error.access.code": "Neispravan kod",
"error.access.login": "Neispravna prijava",
"error.access.panel": "Niste ovlašćeni da uđete u administrativni panel",
"error.access.view": "Niste ovlašćeni da pristupite ovom delu panela",
"error.avatar.create.fail": "Profilna slika nije mogla biti otpremljena",
"error.avatar.delete.fail": "Profilna slika nije mogla biti obrisana",
"error.avatar.dimensions.invalid": "Molimo neka visina i širina Vaše profilne slike budu ispod 3000 piksela",
"error.avatar.mime.forbidden": "Profilna slika mora biti JPEG ili PNG fajl",
"error.blueprint.notFound": "Blueprint \"{name}\" nije mogao biti učitan",
"error.blocks.max.plural": "Ne smete da dodajete više od {max} blokova",
"error.blocks.max.singular": "Ne smete da dodate više od jednog bloka",
"error.blocks.min.plural": "Morate dodati najmanje {min} blokova",
"error.blocks.min.singular": "Morate dodati najmanje jedan blok",
"error.blocks.validation": "Postoji greška u \"{field}\" polju u bloku {index} koji koristi \"{fieldset}\" tip bloka ",
"error.cache.type.invalid": "Neispravan tip keša \"{type}\"",
"error.content.lock.delete": "The version is locked and cannot be deleted",
"error.content.lock.move": "The source version is locked and cannot be moved",
"error.content.lock.publish": "This version is already published",
"error.content.lock.replace": "The version is locked and cannot be replaced",
"error.content.lock.update": "The version is locked and cannot be updated",
"error.entries.max.plural": "You must not add more than {max} entries",
"error.entries.max.singular": "You must not add more than one entry",
"error.entries.min.plural": "You must add at least {min} entries",
"error.entries.min.singular": "You must add at least one entry",
"error.entries.supports": "\"{type}\" field type is not supported for the entries field",
"error.entries.validation": "Postoji greška u redu \"{field}\" na ovom polju {index}",
"error.email.preset.notFound": "Email preset \"{name}\" nije pronađen",
"error.field.converter.invalid": "Neispravan converter \"{converter}\"",
"error.field.link.options": "Invalid options: {options}",
"error.field.type.missing": "Polje \"{ name }\": Tip polja \"{ type }\" ne postoji",
"error.file.changeName.empty": "Naziv ne sme biti prazan",
"error.file.changeName.permission": "Niste ovlašćeni da promenite naziv \"{filename}\"",
"error.file.changeTemplate.invalid": "Šablon za datoteku \"{id}\" se ne može promeniti u \"{template}\" (valid: \"{blueprints}\")",
"error.file.changeTemplate.permission": "Nije vam dozvoljeno da menjate šablon za datoteku \"{id}\"",
"error.file.delete.multiple": "Not all files could be deleted. Try each remaining file individually to see the specific error that prevents deletion.",
"error.file.duplicate": "Fajl sa nazivom \"{filename}\" već postoji",
"error.file.extension.forbidden": "Extension \"{extension}\" nije dozvoljena",
"error.file.extension.invalid": "Nevažeći dodatak: {extension}",
"error.file.extension.missing": "Ekstenzije za \"{filename}\" nedostaju",
"error.file.maxheight": "Visina slike ne sme biti veća od {height} piksela",
"error.file.maxsize": "Datoteka je prevelika",
"error.file.maxwidth": "Širina slike ne sme biti veća od {width} piksela",
"error.file.mime.differs": "Otpremljeni fajl mora biti istog mime tipa \"{mime}\"",
"error.file.mime.forbidden": "Tip medija \"{mime}\" nije dozvoljen",
"error.file.mime.invalid": "Neispravan mime tip: {mime}",
"error.file.mime.missing": "Tip medija za \"{filename}\" nije bilo moguće detektovati",
"error.file.minheight": "Visina slike mora biti najmanje {height} piksela",
"error.file.minsize": "Datoteka je premala",
"error.file.minwidth": "Širina slike mora biti najmanje {width} piksela",
"error.file.name.unique": "Ime datoteke mora biti jedinstveno",
"error.file.name.missing": "Ime fajla ne može biti prazno",
"error.file.notFound": "Fajl \"{filename}\" nije mogao biti pronadjen",
"error.file.orientation": "Orijentacija slike mora biti \"{orientation}\"",
"error.file.sort.permission": "You are not allowed to change the sorting of \"{filename}\"",
"error.file.type.forbidden": "Niste ovlašćeni da otpremate {type} fajlove",
"error.file.type.invalid": "Nevažeći tip datoteke: {type}",
"error.file.undefined": "Fajl nije mogao biti pronadjen",
"error.form.incomplete": "Molimo popravite sve greške u formularu...",
"error.form.notSaved": "Formular nije mogao biti sačuvan",
"error.language.code": "Molimo ukucajte validan kod za jezik",
"error.language.create.permission": "You are not allowed to create a language",
"error.language.delete.permission": "You are not allowed to delete the language",
"error.language.duplicate": "Jezik već postoji",
"error.language.name": "Molimo upišite validno ime za jezik",
"error.language.notFound": "Jezik nije mogao biti pronađen",
"error.language.update.permission": "You are not allowed to update the language",
"error.layout.validation.block": "Postoji greška u \"{field}\" polju u bloku {blockIndex} koji koristi \"{fieldset}\" tip bloka u rasporedu {layoutIndex}",
"error.layout.validation.settings": "Došlo je do greške u {index} podešavanjima ",
"error.license.domain": "Nedostaje domen za licencu",
"error.license.email": "Molimo unesite ispravnu email adresu",
"error.license.format": "Molimo vas unesite važeći kod licence",
"error.license.verification": "Licenca nije mogla biti verifikovana",
"error.login.totp.confirm.invalid": "Neispravan kod",
"error.login.totp.confirm.missing": "Molimo vas unesite trenutni kod",
"error.object.validation": "Postoji greška u \"{label}\" polju: {message}",
"error.offline": "Panel je trenutno van mreže",
"error.page.changeSlug.permission": "Nije Vam dozvoljeno da promenite URL appendix za \"{slug}\"",
"error.page.changeSlug.reserved": "Putanja stranica najvišeg nivoa ne sme da počinje sa \"{path}\"",
"error.page.changeStatus.incomplete": "Ova stranica ima greške i ne može biti objavljena",
"error.page.changeStatus.permission": "Status ove stranice ne može biti promenjen",
"error.page.changeStatus.toDraft.invalid": "Stranica \"{slug}\" ne može biti prebačena u draft",
"error.page.changeTemplate.invalid": "Template za stranicu \"{slug}\" ne može biti promenjen",
"error.page.changeTemplate.permission": "Nije Vam dozvoljeno da promenite template za \"{slug}\"",
"error.page.changeTitle.empty": "Naslov ne može biti prazan",
"error.page.changeTitle.permission": "Nije Vam dozvoljeno da pormenite naslov za \"{slug}\"",
"error.page.create.permission": "Nije Vam dozvoljeno da kreirate \"{slug}\"",
"error.page.delete": "Stranica \"{slug}\" ne može biti obrisana",
"error.page.delete.confirm": "Molimo ukucajte naslov stranice da potvrdite",
"error.page.delete.hasChildren": "Stranica ima podstranice i ne može biti obrisana",
"error.page.delete.multiple": "Not all pages could be deleted. Try each remaining page individually to see the specific error that prevents deletion.",
"error.page.delete.permission": "Nemate ovlašćenja da obrišete \"{slug}\"",
"error.page.draft.duplicate": "Draft stranice sa URL appendix \"{slug}\" već postoji",
"error.page.duplicate": "Stranica sa URL appendix-om \"{slug}\" već postoji",
"error.page.duplicate.permission": "Nije vam dozvoljeno da kopirate \"{slug}\"",
"error.page.move.ancestor": "Stranica se ne može premestiti u sebe",
"error.page.move.directory": "Direktorijum stranice ne može da se premesti",
"error.page.move.duplicate": "Podstranica sa dodatkom URL \"{slug}\" već postoji",
"error.page.move.noSections": "The page \"{parent}\" cannot be a parent of any page because it lacks any pages sections in its blueprint",
"error.page.move.notFound": "Premeštena stranica nije pronađena",
"error.page.move.permission": "Nije vam dozvoljeno da se krećete \"{slug}\"",
"error.page.move.template": "Šablon \"{template}\" nije prihvaćen kao podstranica \"{parent}\"",
"error.page.notFound": "Stranica \"{slug}\" ne može biti pronadjena",
"error.page.num.invalid": "Molimo ukucajte ispravan broj za sortiranje. Brojevi ne mogu biti negativni. ",
"error.page.slug.invalid": "Molimo vas unesite važeći URL dodatak",
"error.page.slug.maxlength": "Dužina poluge mora biti manja od \"{length}\" karaktera ",
"error.page.sort.permission": "Stranica \"{slug}\" ne može biti sortirana",
"error.page.status.invalid": "Molimo podesite ispravan status stranice",
"error.page.undefined": "Stranica ne može biti pronađena",
"error.page.update.permission": "Nije Vam dozvoljeno da ažurirate \"{slug}\"",
"error.section.files.max.plural": "Ne možete dodati više od {max} fajlova u \"{section}\" sekciju",
"error.section.files.max.singular": "Ne možete dodati više od jednog fajla u \"{section}\" sekciju",
"error.section.files.min.plural": "\"{section}\" sekcija zahteva najmanje {min} fajlova",
"error.section.files.min.singular": "\"{section}\" sekcija zahteva najmanje jedan fajl",
"error.section.pages.max.plural": "Ne možete dodati više od {max} stranica u \"{section}\" sekciju",
"error.section.pages.max.singular": "Ne možete dodati više od jedne stranice u \"{section}\" sekciju",
"error.section.pages.min.plural": "\"{section}\" sekcija zahteva najmanje {min} stranica",
"error.section.pages.min.singular": "\"{section}\" sekcija zahteva najmanje jednu stranicu",
"error.section.notLoaded": "Sekcija \"{name}\" nije mogla biti učitana",
"error.section.type.invalid": "Tip sekcije \"{type}\" nije ispravan",
"error.site.changeTitle.empty": "Naslov ne može biti prazan",
"error.site.changeTitle.permission": "Nije Vam dozvoljeno da promenite naziv sajta",
"error.site.update.permission": "Nije Vam dozvoljeno da ažurirate sajt",
"error.structure.validation": "Postoji greška u redu \"{field}\" na ovom polju {index}",
"error.template.default.notFound": "Podrazumevani template ne postoji",
"error.unexpected": "Došlo je do neočekivane greške! Omogućite režim za otklanjanje grešaka za više informacija: https://getkirby.com/docs/reference/system/options/debug",
"error.user.changeEmail.permission": "Nije Vam dozvoljeno da promenite email za korisnika \"{name}\"",
"error.user.changeLanguage.permission": "Nije Vam dozvoljeno da promenite jezik za korisnika \"{name}\"",
"error.user.changeName.permission": "Nije Vam dozvoljeno da promenite ima za korisnika \"{name}\"",
"error.user.changePassword.permission": "Nije Vam dozvoljeno da promenite lozinku za korisnika \"{name}\"",
"error.user.changeRole.lastAdmin": "Rolu poslednjeg admina nije moguće promeniti",
"error.user.changeRole.permission": "Nije Vam dozvoljeno da promenite rolu korisnika \"{name}\"",
"error.user.changeRole.toAdmin": "Nije vam dozvoljeno da unapredite nekoga u ulogu administratora",
"error.user.create.permission": "Nije Vam dozvoljeno da kreirate ovog korisnika",
"error.user.delete": "Korisnik \"{name}\" ne može biti obrisan",
"error.user.delete.lastAdmin": "Poslednji admin ne može biti obrisan",
"error.user.delete.lastUser": "Poslednji korisnik ne može biti obrisan",
"error.user.delete.permission": "Nije Vam dozvoljeno da obrišete korisnika \"{name}\"",
"error.user.duplicate": "Korisnik sa email adresom \"{email}\" već postoji",
"error.user.email.invalid": "Molimo unesite ispravnu email adresu",
"error.user.language.invalid": "Molimo unesite ispravan jezik",
"error.user.notFound": "Korisnik \"{name}\" ne može biti pronadjen",
"error.user.password.excessive": "Molimo vas, unesite ispravnu šifru. Šifra ne sme biti duža od 1000 karaktera.",
"error.user.password.invalid": "Molimo unesite ispravnu lozinku. Lozinke moraju biti barem 8 karaktera dugačke. ",
"error.user.password.notSame": "Lozinke se ne poklapaju",
"error.user.password.undefined": "Ovaj korisnik nema lozinku",
"error.user.password.wrong": "Pogrešna lozinka",
"error.user.role.invalid": "Molimo unesite ispravnu rolu",
"error.user.undefined": "Korisnik nije mogao biti pronadjen",
"error.user.update.permission": "Nije Vam dozvoljeno da ažurirate korisnika \"{name}\"",
"error.validation.accepted": "Molimo potvrdite",
"error.validation.alpha": "Molimo unesite karaktere izmedju a-z",
"error.validation.alphanum": "Molimo unesite samo karaktere izmedju a-z ili brojeve 0-9",
"error.validation.anchor": "Molimo vas unesite ispravan link",
"error.validation.between": "Molimo unesite vrednost izmedju \"{min}\" i \"{max}\"",
"error.validation.boolean": "Molimo potvrdite ili odbijte",
"error.validation.color": "Molimo vas unesite važeću boju u {format} format",
"error.validation.contains": "Molimo unesite vrednost koja sadrži \"{needle}\"",
"error.validation.date": "Molimo unesite ispravan datum",
"error.validation.date.after": "Molimo upišite natum nakon {date}",
"error.validation.date.before": "Molimo upišite datum pre {date}",
"error.validation.date.between": "Molimo dodajte datum između {min} i {max}",
"error.validation.denied": "Molimo odbijte",
"error.validation.different": "Vrednost ne može biti \"{other}\"",
"error.validation.email": "Molimo unesite ispravnu email adresu",
"error.validation.endswith": "Vrednost se mora završiti sa \"{end}\"",
"error.validation.filename": "Molimo unesite ispravno ime fajla",
"error.validation.in": "Molimo unesite nešto od sledećeg: ({in})",
"error.validation.integer": "Molimo unesite ispravan ceo broj",
"error.validation.ip": "Molimo unesite ispravnu IP adresu",
"error.validation.less": "Molimo unesite vrednost manju od {max}",
"error.validation.linkType": "Tip veze nije dozvoljen",
"error.validation.match": "Vrednost se ne uklapa u očekivani šablon",
"error.validation.max": "Molimo unesite vrednost jednsaku ili manju od {max}",
"error.validation.maxlength": "Molimo unestite kražu vrednost. (maks. {max} karaktera)",
"error.validation.maxwords": "Molimo unesite ne više od {max} reč(i)",
"error.validation.min": "Molimo unesite vrednost jednaku ili veću od {min}",
"error.validation.minlength": "Molimo unesite dužu vrednost. (min. {min} karaktera)",
"error.validation.minwords": "Molimo unesite minimun {min} reč(i)",
"error.validation.more": "Molimo vas unesite vrednost veću od {min}",
"error.validation.notcontains": "Molimo vas unesite vrednost koja ne sadrži \"{needle}\"",
"error.validation.notin": "Molimo vas nemojte unositi ništa od sledećeg: ({notIn})",
"error.validation.option": "Molimo izaberite važeću opciju",
"error.validation.num": "Molimo Vas da unesete važeći broj",
"error.validation.required": "Molimo vas unesite nešto",
"error.validation.same": "Molimo vas unesite \"{other}\"",
"error.validation.size": "Veličina vrednosti mora biti \"{size}\"",
"error.validation.startswith": "Vrednost mora početi sa \"{start}\"",
"error.validation.tel": "Molimo vas unesite neformatirani broj telefona",
"error.validation.time": "Molimo vas unesite važeće vreme",
"error.validation.time.after": "Molimo vas unesite vreme posle {time}",
"error.validation.time.before": "Molimo vas unesite vreme pre {time}",
"error.validation.time.between": "Molimo vas unesite vreme između {min} i {max}",
"error.validation.uuid": "Molimo vas unesite važeći UUID",
"error.validation.url": "Molimo vas da unesete važeći URL",
"expand": "Proširite",
"expand.all": "Proširite sve",
"field.invalid": "Polje je nevažeće",
"field.required": "Polje je obavezno",
"field.blocks.changeType": "Promenite tip",
"field.blocks.code.name": "Kod",
"field.blocks.code.language": "Jezik",
"field.blocks.code.placeholder": "Vaš kod…",
"field.blocks.delete.confirm": "Da li zaista želite da izbrišete ovaj blok?",
"field.blocks.delete.confirm.all": "Da li zaista želite da izbrišete sve blokove?",
"field.blocks.delete.confirm.selected": "Da li zaista želite da izbrišete izabrane blokove?",
"field.blocks.empty": "Još nema blokova",
"field.blocks.fieldsets.empty": "Još nema skupova polja",
"field.blocks.fieldsets.label": "Molimo izaberite tip bloka ...",
"field.blocks.fieldsets.paste": "Pritisnite<kbd>{{ shortcut }}</kbd> da biste uvezli rasporede/blokove iz međuspremnika.<small> Biće umetnuti samo oni koji su dozvoljeni u trenutnom polju.</small>",
"field.blocks.gallery.name": "Galerija",
"field.blocks.gallery.images.empty": "Još nema slika",
"field.blocks.gallery.images.label": "Slike",
"field.blocks.heading.level": "Nivo",
"field.blocks.heading.name": "Naslov",
"field.blocks.heading.text": "Tekst",
"field.blocks.heading.placeholder": "Naslov ...",
"field.blocks.figure.back.plain": "Plain",
"field.blocks.figure.back.pattern.light": "Pattern (light)",
"field.blocks.figure.back.pattern.dark": "Pattern (dark)",
"field.blocks.image.alt": "Alternativni tekst",
"field.blocks.image.caption": "Natpis",
"field.blocks.image.crop": "Isecite",
"field.blocks.image.link": "Link",
"field.blocks.image.location": "Lokacija",
"field.blocks.image.location.internal": "Ova veb lokacija\n \n \n",
"field.blocks.image.location.external": "Eksterni izvor",
"field.blocks.image.name": "Slika",
"field.blocks.image.placeholder": "Odaberi sliku",
"field.blocks.image.ratio": "Odnos",
"field.blocks.image.url": "URL slike",
"field.blocks.line.name": "Linija",
"field.blocks.list.name": "Lista",
"field.blocks.markdown.name": "Markdown",
"field.blocks.markdown.label": "Tekst",
"field.blocks.markdown.placeholder": "Markdown …",
"field.blocks.quote.name": "Citat",
"field.blocks.quote.text.label": "Tekst",
"field.blocks.quote.text.placeholder": "Citat ...",
"field.blocks.quote.citation.label": "Citat",
"field.blocks.quote.citation.placeholder": "od …",
"field.blocks.text.name": "Tekst",
"field.blocks.text.placeholder": "Tekst ...",
"field.blocks.video.autoplay": "Autoplay",
"field.blocks.video.caption": "Natpis",
"field.blocks.video.controls": "Controls",
"field.blocks.video.location": "Lokacija",
"field.blocks.video.loop": "Loop",
"field.blocks.video.muted": "Muted",
"field.blocks.video.name": "Video",
"field.blocks.video.placeholder": "Unesite URL video snimka",
"field.blocks.video.poster": "Poster",
"field.blocks.video.preload": "Preload",
"field.blocks.video.url.label": "Video-URL",
"field.blocks.video.url.placeholder": "https://youtube.com/?v=",
"field.entries.delete.confirm.all": "Da li zaista želite da izbrišete sve unose?",
"field.entries.empty": "Još nema unosa",
"field.files.empty": "Još nijedna datoteka nije izabrana",
"field.files.empty.single": "No file selected yet",
"field.layout.change": "Promenite izgled",
"field.layout.delete": "Brisanje rasporeda",
"field.layout.delete.confirm": "Da li zaista želite da obrišete ovaj raspored",
"field.layout.delete.confirm.all": "Da li zaista želite da izbrišete sve rasporede?",
"field.layout.empty": "Još nema redova",
"field.layout.select": "Izaberite raspored",
"field.object.empty": "Još nema informacija",
"field.pages.empty": "Još nijedna stranica nije izabrana",
"field.pages.empty.single": "No page selected yet",
"field.structure.delete.confirm": "Da li zaista želite da izbrišete ovaj red?",
"field.structure.delete.confirm.all": "Da li zaista želite da izbrišete sve unose?",
"field.structure.empty": "Još nema unosa",
"field.users.empty": "Još nije izabran nijedan korisnik",
"field.users.empty.single": "No user selected yet",
"fields.empty": "Još uvek nema polja",
"file": "Datoteka",
"file.blueprint": "Ova datoteka još uvek nema nacrt. Možete definisati podešavanje u <strong>/site/blueprints/files/{blueprint}.yml</strong>",
"file.changeTemplate": "Promenite šablon",
"file.changeTemplate.notice": "Promena šablona datoteke će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Ako novi šablon definiše određena pravila, npr. dimenzije slike, one će se takođe nepovratno primenjivati. Koristite sa oprezom.",
"file.delete.confirm": "Da li zaista želite da izbrišete <br><strong>{filename}</strong>?",
"file.focus.placeholder": "Postavite fokusnu tačku",
"file.focus.reset": "Uklonite fokusnu tačku",
"file.focus.title": "Fokusirajte",
"file.sort": "Promena pozicije",
"files": "Fajlovi",
"files.delete.confirm.selected": "Do you really want to delete the selected files? This action cannot be undone.",
"files.empty": "Još nema fajlova",
"filter": "Filter",
"form.discard": "Discard changes",
"form.discard.confirm": "Do you really want to <strong>discard all your changes</strong>?",
"form.locked": "This content is disabled for you as it is currently edited by another user",
"form.unsaved": "The current changes have not yet been saved",
"form.preview": "Preview changes",
"form.preview.draft": "Preview draft",
"hide": "Sakriti",
"hour": "Čas",
"hue": "Nijansa",
"import": "Uvoz",
"info": "Info",
"insert": "Ubaci",
"insert.after": "Ubaciti posle",
"insert.before": "Ubaciti pre",
"install": "Instaliraj",
"installation": "Instalacija",
"installation.completed": "Panel je instaliran",
"installation.disabled": "Instalater panela je podrazumevano onemogućen na javnim serverima. Molimo vas pokrenite instalater na lokalnoj mašini ili ga omogućite pomoću <code>panel.install</code> opcije",
"installation.issues.accounts": "Fascikla <code>/site/accounts</code> ne postoji ili u nju nije moguće pisati",
"installation.issues.content": "Fascikla <code>/content</code> ne postoji ili u nju nije moguće pisati",
"installation.issues.curl": "Proširenje <code>CURL</code> je potrebno",
"installation.issues.headline": "Panel se ne može instalirati",
"installation.issues.mbstring": "Proširenje <code>MB String</code> je potrebno",
"installation.issues.media": "Fascikla <code>/media</code> ne postoji ili u nju nije moguće pisati",
"installation.issues.php": "Obavezno koristite <code>PHP 8+</code>",
"installation.issues.sessions": "Fascikla <code>/site/sessions</code> ne postoji ili u nju nije moguće pisati",
"language": "Jezik",
"language.code": "Kod",
"language.convert": "Postavi kao podrazumevano",
"language.convert.confirm": "<p>Da li zaista želite da konvertujete <strong>{name}</strong> na podrazumevani jezik? Ovo se ne može poništiti.</p><p> Ako<strong>{name}</strong> ima neprevedenog sadržaja, više neće postojati važeći rezervni deo i delovi vašeg sajta mogu biti prazni.</p>",
"language.create": "Dodajte novi jezik",
"language.default": "Podrazumevani jezik",
"language.delete.confirm": "Da li zaista želite da izbrišete jezik <strong>{name}</strong> uključujući sve prevode? Ovo se ne može poništiti!",
"language.deleted": "Jezik je obrisan",
"language.direction": "Smer čitanja",
"language.direction.ltr": "S leva nadesno",
"language.direction.rtl": "S desna nalevo",
"language.locale": "PHP locale string",
"language.locale.warning": "Koristite prilagođeni lokal. Molimo vas izmenite ga u jezičkoj datoteci u /site/languages",
"language.name": "Ime",
"language.secondary": "Sekundarni jezik",
"language.settings": "Podešavanja jezika",
"language.updated": "Jezik je ažuriran",
"language.variables": "Jezičke varijable",
"language.variables.empty": "Još uvek nema prevoda",
"language.variable.delete.confirm": "Da li zaista želite da izbrišete promenljivu za {key}?",
"language.variable.entries": "Values",
"language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts <code>0</code>, <code>1</code>, <code>2 and more</code>. Use the <code>{count}</code> placeholder to insert the actual count.",
"language.variable.key": "Ključ",
"language.variable.multiple": "Countable?",
"language.variable.multiple.text": "Use different translation strings",
"language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "Promenljiva nije pronađena\n \n \n ",
"language.variable.value": "Vrednost",
"languages": "Jezici",
"languages.default": "Podrazumevani jezik",
"languages.empty": "Još nema jezika",
"languages.secondary": "Sekundarni jezik",
"languages.secondary.empty": "Još nema sekundarnog jezika",
"license": "Licenca",
"license.activate": "Aktivirajte ga sada",
"license.activate.label": "Molimo vas aktivirajte svoju licencu",
"license.activate.domain": "Vaša licenca će biti aktivirana za <strong>{host}</strong>.",
"license.activate.local": "Upravo ćete aktivirati svoju Kirby licencu za vaš lokalni domen <strong>{host}</strong>.Ako će ovaj sajt biti postavljen na javnom domenu, molimo vas aktivirajte ga tamo. Ako je {host} domen za koji želite da koristite svoju licencu, molimo vas nastavite.",
"license.activated": "Aktiviran",
"license.buy": "Kupite licencu",
"license.code": "Kod",
"license.code.help": "Nakon kupovine dobili ste svoju šifru licence putem email. Molimo vas da je kopirate i nalepite ovde.",
"license.code.label": "Molimo vas unesite šifru licence",
"license.status.active.info": "Uključuje nove glavne verzije do {date}",
"license.status.active.label": "Validna licenca",
"license.status.demo.info": "Ovo je demo instalacija",
"license.status.demo.label": "Demo",
"license.status.inactive.info": "Obnovite licencu da biste ažurirali na nove glavne verzije",
"license.status.inactive.label": "Nema novih glavnih verzija",
"license.status.legacy.bubble": "Da li ste spremni da obnovite svoju licencu?",
"license.status.legacy.info": "Vaša licenca ne pokriva ovu verziju",
"license.status.legacy.label": "Molimo vas obnovite vašu licencu",
"license.status.missing.bubble": "Da li ste spremni da pokrenete svoj sajt?",
"license.status.missing.info": "Nema važeće licence",
"license.status.missing.label": "Molimo vas aktivirajte svoju licencu",
"license.status.unknown.info": "The license status is unknown",
"license.status.unknown.label": "Unknown",
"license.manage": "Upravljajte svojom licencom",
"license.purchased": "Kupljeno",
"license.success": "Hvala vam što podržavate Kirby",
"license.unregistered.label": "Neregistrovan",
"link": "Link",
"link.text": "Tekst veze",
"loading": "Učitavanje",
"lock.unsaved": "Nesačuvane promene",
"lock.unsaved.empty": "There are no unsaved changes",
"lock.unsaved.files": "Unsaved files",
"lock.unsaved.pages": "Unsaved pages",
"lock.unsaved.users": "Unsaved accounts",
"lock.isLocked": "Nesačuvane promene {email}",
"lock.unlock": "Otključati",
"lock.unlock.submit": "Otključajte i zamenite nesačuvane promene <strong>{email}</strong>",
"lock.isUnlocked": "Otključao ga je drugi korisnik",
"login": "Prijavi se",
"login.code.label.login": "Kod za prijavu",
"login.code.label.password-reset": "Kod za resetovanje lozinke",
"login.code.placeholder.email": "000 000",
"login.code.placeholder.totp": "000000",
"login.code.text.email": "Ako je vaša adresa e-pošte registrovana, traženi kod je poslat putem e-pošte.",
"login.code.text.totp": "Molimo vas unesite jednokratni kod iz vaše aplikacije za autentifikaciju.",
"login.email.login.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za prijavu na panel {site}.\nSledeći kod za prijavu će biti važećii {timeout} minuta:\n\n{code}\n\nAko niste zahtevali kod za prijavu, zanemarite ovu e-poštu ili kontaktirajte svog administratora ako imate pitanja.\nIz bezbednosnih razloga, NEMOJTE prosleđivati ovu e-poštu.",
"login.email.login.subject": "Vaš kod za prijavu",
"login.email.password-reset.body": "Zdravo {user.nameOrEmail},\n\nNedavno ste zatražili kod za resetovanje lozinke za panel {site}.\nSledeći kod za resetovanje lozinke će biti važeći {timeout} minuta:\n\n{code}\n\nAko niste zahtevali kod za resetovanje lozinke, zanemarite ovu e-poštu ili kontaktirajte svog administratora ako imate pitanja.\nIz bezbednosnih razloga, NEMOJTE prosleđivati ovu e-poštu.",
"login.email.password-reset.subject": "Vaš kod za resetovanje lozinke",
"login.remember": "Ostavi me prijavljenog",
"login.reset": "Resetujte šifru",
"login.toggleText.code.email": "Prijavite se putem e-pošte",
"login.toggleText.code.email-password": "Prijavite se sa lozinkom",
"login.toggleText.password-reset.email": "Zaboravili ste lozinku?",
"login.toggleText.password-reset.email-password": "← Nazad na prijavu",
"login.totp.enable.option": "Podesite jednokratne kodove",
"login.totp.enable.intro": "Aplikacije autentifikacije mogu da generišu jednokratne kodove koji se koriste kao drugi faktor prilikom prijavljivanja na nalog.",
"login.totp.enable.qr.label": "1. Skenirajte ovaj QR kod",
"login.totp.enable.qr.help": "Ne možete da skenirate? Dodajte ključ za podešavanje<code>{secret}</code> manuelno u aplikaciji za autentifikaciju.",
"login.totp.enable.confirm.headline": "2. Potvrdite generisanim kodom",
"login.totp.enable.confirm.text": "Vaša aplikacija generiše novi jednokratni kod svakih 30 sekundi. Unesite trenutni kod da biste završili podešavanje:",
"login.totp.enable.confirm.label": "Trenutni kod",
"login.totp.enable.confirm.help": "Nakon ovog podešavanja, svaki put kada se prijavite tražićemo od vas jednokratni kod.",
"login.totp.enable.success": "Jednokratni kodovi su omogućeni",
"login.totp.disable.option": "Onemogućite jednokratne kodove",
"login.totp.disable.label": "Unesite lozinku da biste onemogućili jednokratne kodove",
"login.totp.disable.help": "U budućnosti, drugi faktor kao što je kod za prijavu poslat putem e-pošte biće zahtevan kada se prijavite. Jednokratne kodove uvek možete ponovo da podesite kasnije.",
"login.totp.disable.admin": "<p>Ovo će onemogućiti jednokratne kodove za<strong>{user}</strong>.</p><p>U budućnosti, drugi faktor kao što je kod za prijavu poslat putem e-pošte biće zahtevan kada se prijave. {user} može ponovo da podesi jednokratne kodove nakon sledećeg prijavljivanja",
"login.totp.disable.success": "Jednokratni kodovi su onemogućeni",
"logout": "Odjavi se",
"merge": "Spojite",
"menu": "Meni",
"meridiem": "AM/PM",
"mime": "Vrsta medija",
"minutes": "Minuti",
"month": "Mesec",
"months.april": "April",
"months.august": "Avgust",
"months.december": "Decembar",
"months.february": "Februar",
"months.january": "Januar",
"months.july": "Jul",
"months.june": "Jun",
"months.march": "Mart",
"months.may": "Maj",
"months.november": "Novembar",
"months.october": "Oktobar",
"months.september": "Septembar",
"more": "Više",
"move": "Pomerite",
"name": "Ime",
"next": "Sledeći",
"night": "Noć",
"no": "ne",
"off": "Isključeno",
"on": "Uključeno",
"open": "Otvorite",
"open.newWindow": "Otvorite u novom prozoru",
"option": "Opcija",
"options": "Opcije",
"options.none": "Nema opcija",
"options.all": "Prikaži sve {count} opcije",
"orientation": "Orijentacija",
"orientation.landscape": "Predeo",
"orientation.portrait": "Portret",
"orientation.square": "Kvadrat",
"page": "Stranica",
"page.blueprint": "Ova stranica još uvek nema blueprint. Možete definisati podešavanje u <strong>/site/blueprints/pages/{blueprint}.yml</strong>",
"page.changeSlug": "Promeni URL",
"page.changeSlug.fromTitle": "Napravi od naslova",
"page.changeStatus": "Promenite status",
"page.changeStatus.position": "Molimo izaberite poziciju",
"page.changeStatus.select": "Odaberite novi status",
"page.changeTemplate": "Promenite šablon",
"page.changeTemplate.notice": "Promena šablona stranice će ukloniti sadržaj za polja koja se ne podudaraju po tipu. Koristite sa oprezom.",
"page.create": "Kreirajte kao {status}",
"page.delete.confirm": "Da li zaista želite da izbrišete <strong>{title}</strong>?",
"page.delete.confirm.subpages": "<strong>Ova stranica ima podstranice</strong>. <br>Sve podstranice će takođe biti izbrisane.",
"page.delete.confirm.title": "Unesite naslov stranice da biste potvrdili",
"page.duplicate.appendix": "Kopiraj",
"page.duplicate.files": "Kopirajte fajlove",
"page.duplicate.pages": "Kopirajte stranice",
"page.move": "Premestite stranicu",
"page.sort": "Promena pozicije",
"page.status": "Status",
"page.status.draft": "Nacrt",
"page.status.draft.description": "Stranica je u radnom režimu i vidljiva je samo prijavljenim urednicima ili putem tajne veze",
"page.status.listed": "Javno",
"page.status.listed.description": "Stranica je javna za svakoga",
"page.status.unlisted": "Nenavedeno",
"page.status.unlisted.description": "Stranica je dostupna samo preko URL-a",
"pages": "Stranice",
"pages.delete.confirm.selected": "Do you really want to delete the selected pages? This action cannot be undone.",
"pages.empty": "Još nema stranica",
"pages.status.draft": "Nacrti",
"pages.status.listed": "Objavljeno",
"pages.status.unlisted": "Neizlistano",
"pagination.page": "Stranica",
"password": "Lozinka",
"paste": "Zalepite",
"paste.after": "Zalepite posle",
"paste.success": "{count} zalepljen!",
"pixel": "Piksel",
"plugin": "Dodatak",
"plugins": "Dodaci",
"prev": "Prethodna",
"preview": "Pregled",
"publish": "Publish",
"published": "Objavljeno",
"remove": "Ukloniti",
"rename": "Preimenovati",
"renew": "Obnovite",
"replace": "Zameniti",
"replace.with": "Zamenite sa",
"retry": "Probajte ponovo",
"revert": "Vratiti se",
"revert.confirm": "Da li zaista želite da <strong> izbrišete sve nesačuvane promene</strong>?",
"role": "Uloga",
"role.admin.description": "Administrator ima sva prava",
"role.admin.title": "Administrator",
"role.all": "Sve",
"role.empty": "Nema korisnika sa ovom ulogom",
"role.description.placeholder": "Nema opisa",
"role.nobody.description": "Ovo je rezervna uloga bez ikakvih dozvola",
"role.nobody.title": "Niko",
"save": "Sačuvaj",
"saved": "Saved",
"search": "Pretraga",
"searching": "Searching",
"search.min": "Unesite {min} karakter za pretragu",
"search.all": "Prikaži sve {count} rezultate ",
"search.results.none": "Nema rezultata",
"section.invalid": "Odeljak je nevažeći",
"section.required": "Odeljak je obavezan",
"security": "Bezbednost",
"select": "Izaberite",
"server": "Server",
"settings": "Podešavanja",
"show": "Prikaži",
"site.blueprint": "Sajt još nema plan. Možete definisati podešavanje u <strong>/site/blueprints/site.yml</strong>",
"size": "Veličina",
"slug": "URL dodatak",
"sort": "Vrsta",
"sort.drag": "Prevucite da biste sortirali…",
"split": "Razdeliti",
"stats.empty": "Nema izveštaja",
"status": "Status",
"system.info.copy": "Copy info",
"system.info.copied": "System info copied",
"system.issues.content": "Čini se da je fascikla sa sadržajem izložena",
"system.issues.eol.kirby": "Vaša instalirana Kirby verzija je stigla do kraja svog životnog veka i neće dobijati dalja bezbednosna ažuriranja",
"system.issues.eol.plugin": "Vaša instalirana verzija { plugin } dodatka je stigla do kraja svog životnog veka i neće dobijati dalja bezbednosna ažuriranja",
"system.issues.eol.php": "Vaše instalirano PHP izdanje { release } je dostiglo kraj svog životnog veka i neće dobijati dalja bezbednosna ažuriranja",
"system.issues.debug": "Otklanjanje grešaka mora biti isključeno u proizvodnji",
"system.issues.git": "Čini se da je .git fascikla izložena",
"system.issues.https": "Preporučujemo HTTPS za sve vaše sajtove",
"system.issues.kirby": "Čini se da je Kirby fascikla izložena",
"system.issues.local": "The site is running locally with relaxed security checks",
"system.issues.site": "Čini se da je fascikla sajta izložena",
"system.issues.vue.compiler": "The Vue template compiler is enabled",
"system.issues.vulnerability.kirby": "Na vašu instalaciju može uticati sledeća ranjivost ({ severity } severity): { description }",
"system.issues.vulnerability.plugin": "Na vašu instalaciju može uticati sledeća ranjivost u { plugin } dodatku ({ severity } severity): { description }",
"system.updateStatus": "Ažuriraj status",
"system.updateStatus.error": "Provera ažuriranja nije uspela",
"system.updateStatus.not-vulnerable": "Nema poznatih ranjivosti",
"system.updateStatus.security-update": "Dostupna besplatna bezbednosna { version } nadogradnja",
"system.updateStatus.security-upgrade": "Nadogradnja { version } sa dostupnim bezbednosnim ispravkama",
"system.updateStatus.unreleased": "Neobjavljena verzija",
"system.updateStatus.up-to-date": "Do datuma",
"system.updateStatus.update": "Dostupno besplatno { version } ažuriranje",
"system.updateStatus.upgrade": "Nadogradnja je { version } dostupna",
"tel": "Telefon",
"tel.placeholder": "+49123456789",
"template": "Šablon",
"theme": "Theme",
"theme.light": "Lights on",
"theme.dark": "Lights off",
"theme.automatic": "Match system default",
"title": "Naslov",
"today": "Danas",
"toolbar.button.clear": "Očisti formatiranje",
"toolbar.button.code": "Kod",
"toolbar.button.bold": "Bold",
"toolbar.button.email": "Email",
"toolbar.button.headings": "Zaglavlje",
"toolbar.button.heading.1": "Zaglavlje 1",
"toolbar.button.heading.2": "Zaglavlje 2",
"toolbar.button.heading.3": "Zaglavlje 3",
"toolbar.button.heading.4": "Zaglavlje 4",
"toolbar.button.heading.5": "Zaglavlje 5",
"toolbar.button.heading.6": "Zaglavlje 6",
"toolbar.button.italic": "Italic",
"toolbar.button.file": "Datoteka",
"toolbar.button.file.select": "Izaberite datoteku",
"toolbar.button.file.upload": "Otpremite datoteku",
"toolbar.button.link": "Link",
"toolbar.button.paragraph": "Paragraf",
"toolbar.button.strike": "Precrtano",
"toolbar.button.sub": "Subscript",
"toolbar.button.sup": "Superscript",
"toolbar.button.ol": "Naručena lista",
"toolbar.button.underline": "Podvući",
"toolbar.button.ul": "Bullet list",
"translation.author": "Branko Matić",
"translation.direction": "ltr",
"translation.name": "Srpski",
"translation.locale": "sr_RS@latin",
"type": "Tip",
"upload": "Otpremi",
"upload.error.cantMove": "Otpremljena datoteka nije mogla da se premesti",
"upload.error.cantWrite": "Neuspešno prebacivanje datoteka na disk",
"upload.error.default": "Nije moguće otpremiti datoteku",
"upload.error.extension": "Otpremanje datoteke je zaustavljeno ekstenzijom",
"upload.error.formSize": "Otpremljena datoteka premašuje MAX_FILE_SIZE direktivu koja je navedena u obrascu",
"upload.error.iniPostSize": "Otpremljena datoteka premašuje post_max_size direktivu u php.ini",
"upload.error.iniSize": "Otpremljena datoteka premašuje upload_max_filesize direktivu u php.ini",
"upload.error.noFile": "Nijedna datoteka nije otpremljena",
"upload.error.noFiles": "Nijedna datoteka nije otpremljena",
"upload.error.partial": "Otpremljena datoteka je samo delimično otpremljena",
"upload.error.tmpDir": "Nedostaje privremena fascikla",
"upload.errors": "Greška",
"upload.progress": "Otpremanje…",
"url": "Url",
"url.placeholder": "https://example.com",
"user": "Korisnik",
"user.blueprint": "Možete definisati dodatne odeljke i polja obrasca za ovu korisničku ulogu u<strong>site/blueprints/users/{blueprint}.yml</strong>",
"user.changeEmail": "Promenite E-mail",
"user.changeLanguage": "Promenite jezik",
"user.changeName": "Preimenujte ovog korisnika",
"user.changePassword": "Promenite lozinku",
"user.changePassword.current": "Your current password",
"user.changePassword.new": "Nova lozinka",
"user.changePassword.new.confirm": "Potvrdite novu lozinku…",
"user.changeRole": "Promenite ulogu",
"user.changeRole.select": "Izaberite novu ulogu",
"user.create": "Dodajte novog korisnika",
"user.delete": "Izbrišite ovog korisnika",
"user.delete.confirm": "Da li zaista želite da izbrišete <br><strong>{email}</strong>?",
"users": "Korisnici",
"version": "Verzija",
"version.changes": "Changed version",
"version.compare": "Compare versions",
"version.current": "Trenutna verzija",
"version.latest": "Najnovija verzija",
"versionInformation": "Informacije o verziji",
"view": "View",
"view.account": "Tvoj nalog",
"view.installation": "Instalacija",
"view.languages": "Jezici",
"view.resetPassword": "Resetujte šifru",
"view.site": "Sajt",
"view.system": "Sistem",
"view.users": "Korisnici",
"welcome": "Dobrodošli",
"year": "Godina",
"yes": "Da"
}

View file

@ -0,0 +1,799 @@
{
"account.changeName": "變更帳號名稱",
"account.delete": "刪除帳號",
"account.delete.confirm": "你確定要刪除這個帳號嗎?",
"activate": "啟用",
"add": "\u65b0\u589e",
"alpha": "字母順序",
"author": "作者",
"avatar": "\u4f7f\u7528\u8005\u7167\u7247",
"back": "返回",
"cancel": "\u53d6\u6d88",
"change": "\u8b8a\u66f4",
"close": "\u95dc\u9589",
"changes": "變更",
"confirm": "儲存",
"collapse": "收合",
"collapse.all": "全部收合",
"color": "顏色",
"coordinates": "座標",
"copy": "Copy",
"copy.all": "全部複製",
"copy.success": "複製成功",
"copy.success.multiple": "{count} 資料已複製",
"copy.url": "複製網址",
"create": "建立",
"custom": "自訂",
"date": "日期",
"date.select": "選擇日期",
"day": "日",
"days.fri": "\u4e94",
"days.mon": "\u4e00",
"days.sat": "\u516d",
"days.sun": "\u65e5",
"days.thu": "\u56db",
"days.tue": "\u4e8c",
"days.wed": "\u4e09",
"debugging": "除錯中",
"delete": "\u522a\u9664",
"delete.all": "全部刪除",
"dialog.fields.empty": "沒有可用的欄位",
"dialog.files.empty": "沒有可用的檔案",
"dialog.pages.empty": "沒有可用的頁面",
"dialog.text.empty": "沒有可用的文字",
"dialog.users.empty": "沒有可用的使用者",
"dimensions": "尺寸",
"disable": "停用",
"disabled": "已停用",
"discard": "\u653e\u68c4",
"drawer.fields.empty": "沒有欄位可顯示",
"domain": "網域",
"download": "下載",
"duplicate": "建立副本",
"edit": "\u7de8\u8f2f",
"email": "電子郵件",
"email.placeholder": "mail@example.com",
"enter": "輸入",
"entries": "資料項目",
"entry": "進入",
"environment": "環境",
"error": "錯誤",
"error.access.code": "無效的存取碼",
"error.access.login": "請先登入",
"error.access.panel": "你沒有進入控制台的權限",
"error.access.view": "你沒有瀏覽這個項目的權限",
"error.avatar.create.fail": "無法建立使用者照片",
"error.avatar.delete.fail": "\u7121\u6cd5\u522a\u9664\u4f7f\u7528\u8005\u7167\u7247",
"error.avatar.dimensions.invalid": "請將個人檔案圖片的寬度和高度控製在 3000 畫素以下",
"error.avatar.mime.forbidden": "\u88ab\u7981\u6b62\u7684 mime \u985e\u578b",
"error.blueprint.notFound": "無法載入藍圖「{name}」",
"error.blocks.max.plural": "最多只能加入 {max} 個區塊",
"error.blocks.max.singular": "最多只能加入 1 個區塊",
"error.blocks.min.plural": "至少需要 {min} 個區塊",
"error.blocks.min.singular": "至少需要 1 個區塊",
"error.blocks.validation": "使用「{fieldset}」區塊類型的區塊 {index} 中的「{field}」欄位出錯",
"error.cache.type.invalid": "無效快取類型 \"{type}\"",
"error.content.lock.delete": "內容鎖定中,無法刪除",
"error.content.lock.move": "內容鎖定中,無法移動",
"error.content.lock.publish": "內容鎖定中,無法發佈",
"error.content.lock.replace": "內容鎖定中,無法替換",
"error.content.lock.update": "內容鎖定中,無法更新",
"error.entries.max.plural": "最多只能加入 {max} 筆資料",
"error.entries.max.singular": "最多只能加入 1 筆資料",
"error.entries.min.plural": "至少需要 {min} 筆資料",
"error.entries.min.singular": "至少需要 1 筆資料",
"error.entries.supports": "「{type}」欄位類型不支援指定的資料類型",
"error.entries.validation": "行 {index} 中的「{field}」欄位出錯。",
"error.email.preset.notFound": "找不到電子信箱預設設定「{name}」",
"error.field.converter.invalid": "欄位轉換無效「{converter}」",
"error.field.link.options": "連結欄位的選項格式錯誤:「{options}」",
"error.field.type.missing": "欄位「{ name }」:欄位類型「{ type }」不存在",
"error.file.changeName.empty": "請輸入新的檔名",
"error.file.changeName.permission": "你沒有變更「{filename}」檔名的權限",
"error.file.changeTemplate.invalid": "檔案「{id}」的樣板無法變更為「{template}」(有效:「{blueprints}」)。",
"error.file.changeTemplate.permission": "你沒有變更「{id}」樣板的權限",
"error.file.delete.multiple": "刪除多個檔案時發生錯誤",
"error.file.duplicate": "檔案「{filename}」重複",
"error.file.extension.forbidden": "\u88ab\u7981\u6b62\u7684\u526f\u6a94\u540d",
"error.file.extension.invalid": "無效的副檔名:{extension}",
"error.file.extension.missing": "檔案「{filename}」沒有副檔名",
"error.file.maxheight": "檔案高度不能超過 {max} 像素",
"error.file.maxsize": "檔案太大",
"error.file.maxwidth": "檔案寬度不能超過 {max} 像素",
"error.file.mime.differs": "上傳的檔案必須是相同的 mime 類型「{mime}」",
"error.file.mime.forbidden": "不允許使用媒體類型「{mime}」。",
"error.file.mime.invalid": "無效的 MIME 類型:「{mime}」",
"error.file.mime.missing": "無法偵測「{filename}」檔案的媒體類型",
"error.file.minheight": "檔案高度不能小於 {min} 像素",
"error.file.minsize": "檔案太小",
"error.file.minwidth": "檔案寬度不能小於 {min} 像素",
"error.file.name.unique": "檔名已經存在",
"error.file.name.missing": "請輸入檔名",
"error.file.notFound": "\u627e\u4e0d\u5230\u6a94\u6848",
"error.file.orientation": "影像的方向必須是「{orientation}」",
"error.file.sort.permission": "你沒有變更「{filename}」排序的權限",
"error.file.type.forbidden": "此「{type}」的檔案不允許上傳",
"error.file.type.invalid": "無效的檔案類型:{filename}",
"error.file.undefined": "\u627e\u4e0d\u5230\u6a94\u6848",
"error.form.incomplete": "表單尚未填寫完成",
"error.form.notSaved": "表單無法儲存,請檢查是否有錯誤",
"error.language.code": "語言代碼無效",
"error.language.create.permission": "你沒有新增語言的權限",
"error.language.delete.permission": "你沒有刪除語言的權限",
"error.language.duplicate": "語言已經存在",
"error.language.name": "語言名稱無效",
"error.language.notFound": "找不到語言",
"error.language.update.permission": "你沒有變更語言設定的權限",
"error.layout.validation.block": "在版面組態第 {layoutIndex} 區塊中,使用「{fieldset}」區塊類型的第 {blockIndex} 區塊內,欄位「{field}」發生錯誤",
"error.layout.validation.settings": "第 {index} 個版面組態的設定有誤",
"error.license.domain": "授權的網域名稱無效或不符",
"error.license.email": "Please enter a valid email address",
"error.license.format": "授權碼格式錯誤",
"error.license.verification": "授權驗證失敗",
"error.login.totp.confirm.invalid": "驗證碼無效,請重新確認",
"error.login.totp.confirm.missing": "請輸入驗證碼",
"error.object.validation": "欄位「{label}」有錯誤:\n{message}",
"error.offline": "系統目前離線,請稍後再試",
"error.page.changeSlug.permission": "\u7121\u6cd5\u66f4\u6539\u9801\u9762 URL",
"error.page.changeSlug.reserved": "頂層頁面的路徑不得以「{path}」作為開頭",
"error.page.changeStatus.incomplete": "請填寫所有必要欄位後再變更狀態",
"error.page.changeStatus.permission": "你沒有變更頁面狀態的權限",
"error.page.changeStatus.toDraft.invalid": "頁面「{slug}」無法轉換為草稿狀態",
"error.page.changeTemplate.invalid": "頁面「{slug}」的樣板無法變更",
"error.page.changeTemplate.permission": "你沒有變更「{slug}」樣板的權限",
"error.page.changeTitle.empty": "請輸入頁面標題",
"error.page.changeTitle.permission": "你沒有變更「{slug}」標題的權限",
"error.page.create.permission": "你沒有建立「{slug}」標題的權限",
"error.page.delete": "無法刪除頁面「{slug}」",
"error.page.delete.confirm": "你確定要刪除這個頁面嗎?",
"error.page.delete.hasChildren": "此頁面下有子頁面,請先刪除子頁面",
"error.page.delete.multiple": "刪除多個頁面時發生錯誤",
"error.page.delete.permission": "你沒有刪除「{slug}」的權限",
"error.page.draft.duplicate": "已經有草稿頁面使用「{slug}」作為網址附加碼",
"error.page.duplicate": "已有使用網址附加碼「{slug}」的頁面存在",
"error.page.duplicate.permission": "你沒有複製「{slug}」的權限",
"error.page.move.ancestor": "無法移動到其子孫頁面下",
"error.page.move.directory": "移動頁面失敗,資料夾錯誤",
"error.page.move.duplicate": "已有使用網址附加碼「{slug}」的子頁面存在",
"error.page.move.noSections": "頁面「{parent}」因藍圖中未包含任何頁面區段,無法成為其他頁面的上層頁面",
"error.page.move.notFound": "找不到要移動的頁面",
"error.page.move.permission": "你沒有移動「{slug}」的權限",
"error.page.move.template": "樣板「{template}」不被允許作為「{parent}」的子頁面",
"error.page.notFound": "\u627e\u4e0d\u5230\u9801\u9762",
"error.page.num.invalid": "頁面排序編號無效",
"error.page.slug.invalid": "頁面網址無效,只能使用小寫英數、連字號與底線",
"error.page.slug.maxlength": "網址附加碼長度必須少於 {length} 個字元",
"error.page.sort.permission": "頁面「{slug}」無法進行排序",
"error.page.status.invalid": "頁面狀態無效",
"error.page.undefined": "\u627e\u4e0d\u5230\u9801\u9762",
"error.page.update.permission": "你沒有更新「{slug}」的權限",
"error.section.files.max.plural": "「{section}」區段中最多只能加入 {max} 個檔案",
"error.section.files.max.singular": "「{section}」區段中最多只能加入 1 個檔案",
"error.section.files.min.plural": "「{section}」區段中至少需要 {min} 個檔案",
"error.section.files.min.singular": "「{section}」區段中至少需要 1 個檔案",
"error.section.pages.max.plural": "「{section}」區段中最多只能加入 {max} 個頁面",
"error.section.pages.max.singular": "「{section}」區段中最多只能加入 1 個頁面",
"error.section.pages.min.plural": "「{section}」區段中至少需要 {min} 個頁面",
"error.section.pages.min.singular": "「{section}」區段中至少需要 1 個頁面",
"error.section.notLoaded": "無法載入「{name}」區段",
"error.section.type.invalid": "區段類型「{type}」無效",
"error.site.changeTitle.empty": "網站標題不得為空",
"error.site.changeTitle.permission": "你沒有變更網站標題的權限",
"error.site.update.permission": "你沒有變更網站設定的權限",
"error.structure.validation": "第 {index} 列的「{field}」欄位發生錯誤",
"error.template.default.notFound": "找不到預設樣板",
"error.unexpected": "發生未預期的錯誤如需更多資訊請啟用除錯模式https://getkirby.com/docs/reference/system/options/debug",
"error.user.changeEmail.permission": "你沒有變更使用者「{name}」電子郵件的權限",
"error.user.changeLanguage.permission": "你沒有變更使用者「{name}」語言的權限",
"error.user.changeName.permission": "你沒有變更使用者「{name}」名稱的權限",
"error.user.changePassword.permission": "你沒有變更使用者「{name}」密碼的權限",
"error.user.changeRole.lastAdmin": "無法取消最後一位管理員的權限",
"error.user.changeRole.permission": "你沒有變更使用者「{name}」角色的權限",
"error.user.changeRole.toAdmin": "你無法將自己升級為管理員",
"error.user.create.permission": "你沒有新增使用者的權限",
"error.user.delete": "\u8a72\u4f7f\u7528\u8005\u4e0d\u53ef\u88ab\u522a\u9664",
"error.user.delete.lastAdmin": "\u4f60\u7121\u6cd5\u522a\u9664\u6700\u5f8c\u7684\u7ba1\u7406\u8005",
"error.user.delete.lastUser": "無法刪除最後一位使用者",
"error.user.delete.permission": "\u4f60\u7121\u6cd5\u4fee\u6539\u6b64\u4f7f\u7528\u8005",
"error.user.duplicate": "已有使用者使用電子郵件位址「{email}」",
"error.user.email.invalid": "Please enter a valid email address",
"error.user.language.invalid": "無效的語言代碼",
"error.user.notFound": "\u627e\u4e0d\u5230\u4f7f\u7528\u8005",
"error.user.password.excessive": "請輸入有效的密碼。密碼長度不得超過 1000 個字元。",
"error.user.password.invalid": "請輸入有效的密碼。密碼長度至少需為 8 個字元。",
"error.user.password.notSame": "\u8acb\u78ba\u8a8d\u5bc6\u78bc\u7121\u8aa4",
"error.user.password.undefined": "請輸入密碼",
"error.user.password.wrong": "密碼錯誤",
"error.user.role.invalid": "權限設定無效",
"error.user.undefined": "找不到使用者",
"error.user.update.permission": "你沒有更新使用者「{name}」的權限",
"error.validation.accepted": "請勾選此選項",
"error.validation.alpha": "只能包含英文字母",
"error.validation.alphanum": "僅能輸入 a-z 的英文字母或數字 0-9",
"error.validation.anchor": "無效的錨點格式",
"error.validation.between": "必須介於 {min} 到 {max} 之間",
"error.validation.boolean": "請選擇是或否",
"error.validation.color": "請輸入有效的顏色,格式需為 {format}",
"error.validation.contains": "必須包含「{value}」",
"error.validation.date": "無效的日期格式",
"error.validation.date.after": "日期必須晚於 {date}",
"error.validation.date.before": "日期必須早於 {date}",
"error.validation.date.between": "日期必須介於 {min} 到 {max} 之間",
"error.validation.denied": "不允許的值",
"error.validation.different": "此欄位必須與 {other} 不同",
"error.validation.email": "Please enter a valid email address",
"error.validation.endswith": "必須以「{value}」結尾",
"error.validation.filename": "無效的檔名",
"error.validation.in": "請輸入以下其中一項:({in})",
"error.validation.integer": "請輸入整數",
"error.validation.ip": "請輸入有效的 IP 位址",
"error.validation.less": "數值必須小於 {max}",
"error.validation.linkType": "無效的連結類型",
"error.validation.match": "格式不正確",
"error.validation.max": "數值不得超過 {max}",
"error.validation.maxlength": "請輸入較短的內容(最多 {max} 個字元)",
"error.validation.maxwords": "請輸入不超過 {max} 個詞語 (words)",
"error.validation.min": "數值不得小於 {min}",
"error.validation.minlength": "請輸入較長的內容(至少 {min} 個字元)",
"error.validation.minwords": "請輸入至少 {min} 個詞語words",
"error.validation.more": "數值必須大於 {min}",
"error.validation.notcontains": "不得包含「{value}」",
"error.validation.notin": "請不要輸入以下任一項:({notIn}",
"error.validation.option": "請選擇有效的選項",
"error.validation.num": "請輸入數字",
"error.validation.required": "此欄位為必填",
"error.validation.same": "此欄位必須與 {other} 相同",
"error.validation.size": "大小必須為 {size}",
"error.validation.startswith": "必須以「{value}」開頭",
"error.validation.tel": "請輸入有效的電話號碼",
"error.validation.time": "無效的時間格式",
"error.validation.time.after": "時間必須晚於 {time}",
"error.validation.time.before": "時間必須早於 {time}",
"error.validation.time.between": "時間必須介於 {min} 到 {max} 之間",
"error.validation.uuid": "請輸入有效的 UUID",
"error.validation.url": "請輸入有效的網址",
"expand": "展開",
"expand.all": "全部展開",
"field.invalid": "欄位無效",
"field.required": "此欄位為必填",
"field.blocks.changeType": "變更區塊類型",
"field.blocks.code.name": "程式碼",
"field.blocks.code.language": "慣用語言",
"field.blocks.code.placeholder": "輸入程式碼…",
"field.blocks.delete.confirm": "你確定要刪除這個區塊嗎?",
"field.blocks.delete.confirm.all": "你確定要刪除所有區塊嗎?",
"field.blocks.delete.confirm.selected": "你確定要刪除已選取的區塊嗎?",
"field.blocks.empty": "尚未加入任何區塊",
"field.blocks.fieldsets.empty": "沒有可用的區塊類型",
"field.blocks.fieldsets.label": "新增區塊",
"field.blocks.fieldsets.paste": "按下 {{shortcut}} 可從剪貼簿匯入版面/區塊,僅會插入目前欄位允許的區塊類型。",
"field.blocks.gallery.name": "圖集",
"field.blocks.gallery.images.empty": "尚未加入圖片",
"field.blocks.gallery.images.label": "圖片",
"field.blocks.heading.level": "標題層級",
"field.blocks.heading.name": "標題",
"field.blocks.heading.text": "標題文字",
"field.blocks.heading.placeholder": "輸入標題…",
"field.blocks.figure.back.plain": "純色背景",
"field.blocks.figure.back.pattern.light": "圖樣(亮色)",
"field.blocks.figure.back.pattern.dark": "圖樣(暗色)",
"field.blocks.image.alt": "替代文字",
"field.blocks.image.caption": "圖片說明",
"field.blocks.image.crop": "裁切",
"field.blocks.image.link": "連結",
"field.blocks.image.location": "圖片位置",
"field.blocks.image.location.internal": "內部上傳",
"field.blocks.image.location.external": "外部連結",
"field.blocks.image.name": "圖片",
"field.blocks.image.placeholder": "拖曳或點擊以選擇圖片",
"field.blocks.image.ratio": "顯示比例",
"field.blocks.image.url": "圖片網址",
"field.blocks.line.name": "分隔線",
"field.blocks.list.name": "清單",
"field.blocks.markdown.name": "Markdown",
"field.blocks.markdown.label": "內容",
"field.blocks.markdown.placeholder": "輸入 Markdown 內容…",
"field.blocks.quote.name": "引言",
"field.blocks.quote.text.label": "引言內容",
"field.blocks.quote.text.placeholder": "輸入引言文字…",
"field.blocks.quote.citation.label": "出處",
"field.blocks.quote.citation.placeholder": "輸入引用來源…",
"field.blocks.text.name": "段落",
"field.blocks.text.placeholder": "輸入段落內容…",
"field.blocks.video.autoplay": "自動播放",
"field.blocks.video.caption": "影片說明",
"field.blocks.video.controls": "顯示控制列",
"field.blocks.video.location": "影片位置",
"field.blocks.video.loop": "重複播放",
"field.blocks.video.muted": "靜音",
"field.blocks.video.name": "影片",
"field.blocks.video.placeholder": "貼上影片網址或拖曳影片",
"field.blocks.video.poster": "預覽縮圖",
"field.blocks.video.preload": "預先載入",
"field.blocks.video.url.label": "影片網址",
"field.blocks.video.url.placeholder": "https://youtube.com/?v=",
"field.entries.delete.confirm.all": "你確定要刪除所有資料嗎?",
"field.entries.empty": "還沒有資料",
"field.files.empty": "沒有選取任何檔案",
"field.files.empty.single": "尚未選取檔案",
"field.layout.change": "變更版面配置",
"field.layout.delete": "刪除版面",
"field.layout.delete.confirm": "你確定要刪除這個版面嗎?",
"field.layout.delete.confirm.all": "你確定要刪除所有版面嗎?",
"field.layout.empty": "尚未設定任何版面",
"field.layout.select": "選擇版面",
"field.object.empty": "尚未填寫內容",
"field.pages.empty": "沒有選取任何頁面",
"field.pages.empty.single": "尚未選取頁面",
"field.structure.delete.confirm": "\u4f60\u771f\u7684\u8981\u522a\u9664\u6b64\u7b46\u8cc7\u6599\u55ce\uff1f",
"field.structure.delete.confirm.all": "你確定要刪除所有資料嗎?",
"field.structure.empty": "尚未加入任何資料",
"field.users.empty": "沒有選取任何使用者",
"field.users.empty.single": "尚未選取使用者",
"fields.empty": "此頁面沒有設定欄位",
"file": "檔案",
"file.blueprint": "此檔案尚未設定藍圖。你可以在 /site/blueprints/files/{blueprint}.yml 中定義設定內容",
"file.changeTemplate": "變更樣板",
"file.changeTemplate.notice": "變更樣板可能會導致資料遺失",
"file.delete.confirm": "\u78ba\u8a8d\u522a\u9664\u6a94\u6848\uff1f",
"file.focus.placeholder": "選取聚焦區域",
"file.focus.reset": "重設焦點",
"file.focus.title": "圖片焦點",
"file.sort": "變更檔案順序",
"files": "附加檔案",
"files.delete.confirm.selected": "你確定要刪除已選取的檔案嗎?",
"files.empty": "此處沒有檔案",
"filter": "篩選",
"form.discard": "放棄變更",
"form.discard.confirm": "你確定要放棄未儲存的變更嗎?",
"form.locked": "此表單已鎖定,無法編輯",
"form.unsaved": "尚有未儲存的變更",
"form.preview": "預覽內容",
"form.preview.draft": "預覽草稿",
"hide": "隱藏",
"hour": "時",
"hue": "色相",
"import": "匯入",
"info": "資訊",
"insert": "\u63d2\u5165",
"insert.after": "插入在後",
"insert.before": "插入在前",
"install": "安裝",
"installation": "安裝",
"installation.completed": "安裝完成",
"installation.disabled": "安裝功能已停用",
"installation.issues.accounts": "\u60a8\u6c92\u6709\u300c\/site\/accounts\u300d\u8cc7\u6599\u593e\u7684\u4fee\u6539\u6b0a\u9650",
"installation.issues.content": "\u60a8\u6c92\u6709\u300c\/content\u300d\u8cc7\u6599\u593e\u7684\u4fee\u6539\u6b0a\u9650",
"installation.issues.curl": "伺服器未啟用 cURL可能會導致某些功能無法使用",
"installation.issues.headline": "請先解決下列安裝問題:",
"installation.issues.mbstring": "PHP 尚未啟用 mbstring 擴充套件",
"installation.issues.media": "無法建立 /media 資料夾或寫入權限不足",
"installation.issues.php": "請確保使用 PHP 8 以上版本",
"installation.issues.sessions": "PHP sessions 未正確啟用,請檢查伺服器設定",
"language": "\u6163\u7528\u8a9e\u8a00",
"language.code": "語言代碼",
"language.convert": "轉換語言",
"language.convert.confirm": "你確定要將所有內容轉換為此語言嗎?你確定要將「{name}」轉換為預設語言嗎?此操作無法還原。\n如果「{name}」中有未翻譯的內容,將不會有可用的預設語言做為備援,可能會導致網站部份內容為空。",
"language.create": "新增語言",
"language.default": "預設語言",
"language.delete.confirm": "你確定要刪除語言「{name}」及其所有翻譯內容嗎?此操作無法還原!",
"language.deleted": "語言已刪除",
"language.direction": "書寫方向",
"language.direction.ltr": "由左至右",
"language.direction.rtl": "由右至左",
"language.locale": "PHP 語系字串",
"language.locale.warning": "請確認區域設定符合 PHP 認可的格式",
"language.name": "語言名稱",
"language.secondary": "次要語言",
"language.settings": "語言設定",
"language.updated": "語言已更新",
"language.variables": "語言變數",
"language.variables.empty": "尚未設定語言變數",
"language.variable.delete.confirm": "你確定要刪除「{key}」這個變數嗎?",
"language.variable.entries": "Values",
"language.variable.entries.help": "Each string will be used for its matching count, e.g. three strings will match in order to counts <code>0</code>, <code>1</code>, <code>2 and more</code>. Use the <code>{count}</code> placeholder to insert the actual count.",
"language.variable.key": "變數名稱",
"language.variable.multiple": "Countable?",
"language.variable.multiple.text": "Use different translation strings",
"language.variable.multiple.help": "You can use different values depending on a count you pass along with the language variable, allowing you to create dynamic translations, e.g. singular and plural.",
"language.variable.notFound": "找不到指定的語言變數",
"language.variable.value": "變數內容",
"languages": "語言清單",
"languages.default": "預設語言",
"languages.empty": "尚未設定語言",
"languages.secondary": "次要語言清單",
"languages.secondary.empty": "尚未設定次要語言",
"license": "Kirby \u6191\u8b49",
"license.activate": "啟用授權",
"license.activate.label": "輸入您的授權碼以啟用 Kirby",
"license.activate.domain": "你的授權將會啟用於 {host}",
"license.activate.local": "你即將為本機網域 {host} 啟用你的 Kirby 授權。\n如果這個網站將部署到公開網域請改為在公開網域上啟用授權。\n如果 {host} 就是你要使用授權的網域,請繼續操作。",
"license.activated": "授權已啟用",
"license.buy": "購買授權",
"license.code": "授權碼",
"license.code.help": "您可以在購買確認信中找到授權碼",
"license.code.label": "輸入授權碼",
"license.status.active.info": "包含至 {date} 前的新主要版本",
"license.status.active.label": "已啟用",
"license.status.demo.info": "此為試用版安裝,僅供開發或測試使用",
"license.status.demo.label": "試用中",
"license.status.inactive.info": "此網站尚未啟用授權",
"license.status.inactive.label": "未啟用",
"license.status.legacy.bubble": "舊版授權",
"license.status.legacy.info": "此授權使用的是舊版授權機制",
"license.status.legacy.label": "舊版授權",
"license.status.missing.bubble": "缺少授權",
"license.status.missing.info": "請輸入有效授權碼以使用 Kirby",
"license.status.missing.label": "未授權",
"license.status.unknown.info": "無法確認授權狀態,請檢查您的網路連線",
"license.status.unknown.label": "未知狀態",
"license.manage": "管理授權",
"license.purchased": "您已購買授權",
"license.success": "授權啟用成功",
"license.unregistered.label": "未註冊",
"link": "\u9023\u7d50",
"link.text": "\u9023\u7d50\u6587\u5b57",
"loading": "載入中…",
"lock.unsaved": "有尚未儲存的變更",
"lock.unsaved.empty": "所有變更已儲存",
"lock.unsaved.files": "有檔案變更尚未儲存",
"lock.unsaved.pages": "有頁面變更尚未儲存",
"lock.unsaved.users": "有使用者變更尚未儲存",
"lock.isLocked": "{email} 尚未儲存的變更",
"lock.unlock": "解除鎖定",
"lock.unlock.submit": "解鎖並覆蓋 {email} 尚未儲存的變更",
"lock.isUnlocked": "目前已解除鎖定",
"login": "登入",
"login.code.label.login": "使用一次性登入碼登入",
"login.code.label.password-reset": "重設密碼的安全碼",
"login.code.placeholder.email": "000 000",
"login.code.placeholder.totp": "000000",
"login.code.text.email": "我們已寄送一組登入連結至你的電子信箱",
"login.code.text.totp": "請輸入兩步驟驗證碼",
"login.email.login.body": "嗨 {user.nameOrEmail}\n\n你最近請求了 {site} 控製臺的登入驗證碼。\n以下驗證碼在 {timeout} 分鐘內有效:\n\n{code}\n\n如果你並未請求此驗證碼請忽略此封信如有疑問請聯絡你的管理員。\n為了安全起見請不要轉寄此封電子郵件。",
"login.email.login.subject": "您的 Kirby 登入連結",
"login.email.password-reset.body": "嗨 {user.nameOrEmail}\n\n你最近請求了 {site} 控製臺的密碼重設驗證碼。\n以下驗證碼在 {timeout} 分鐘內有效:\n\n{code}\n\n如果你並未請求此驗證碼請忽略此封信如有疑問請聯絡你的管理員。\n為了安全起見請不要轉寄此封電子郵件。",
"login.email.password-reset.subject": "您的 Kirby 密碼重設連結",
"login.remember": "記住我",
"login.reset": "重設密碼",
"login.toggleText.code.email": "使用電子信箱登入",
"login.toggleText.code.email-password": "使用電子信箱與密碼登入",
"login.toggleText.password-reset.email": "忘記密碼?使用電子信箱重設",
"login.toggleText.password-reset.email-password": "返回使用密碼登入",
"login.totp.enable.option": "設定一次性驗證碼",
"login.totp.enable.intro": "增加帳號安全性,需使用驗證器應用程式",
"login.totp.enable.qr.label": "1. 掃描這個 QR 碼",
"login.totp.enable.qr.help": "無法掃描嗎?請將設定金鑰 {secret} 手動加入你的驗證器 App。",
"login.totp.enable.confirm.headline": "2. 使用產生的驗證碼進行確認",
"login.totp.enable.confirm.text": "你的 App 每 30 秒會產生一組新的一次性驗證碼。請輸入目前的驗證碼以完成設定:",
"login.totp.enable.confirm.label": "驗證碼",
"login.totp.enable.confirm.help": "來自驗證器 App 的 6 位數碼",
"login.totp.enable.success": "已成功啟用兩步驟驗證",
"login.totp.disable.option": "停用兩步驟驗證",
"login.totp.disable.label": "停用驗證",
"login.totp.disable.help": "您的帳號將不再需要驗證器登入",
"login.totp.disable.admin": "這將會停用 {user} 的一次性驗證碼。\n未來他們登入時系統會改為要求其他第二驗證方式例如透過電子郵件傳送的登入碼。\n{user} 可於下次登入後重新設定一次性驗證碼。",
"login.totp.disable.success": "已成功停用兩步驟驗證",
"logout": "登出",
"merge": "合併",
"menu": "選單",
"meridiem": "上午/下午",
"mime": "MIME 類型",
"minutes": "分鐘",
"month": "月",
"months.april": "\u56db\u6708",
"months.august": "\u516b\u6708",
"months.december": "\u5341\u4e8c\u6708",
"months.february": "二月",
"months.january": "\u4e00\u6708",
"months.july": "\u4e03\u6708",
"months.june": "\u516d\u6708",
"months.march": "\u4e09\u6708",
"months.may": "\u4e94\u6708",
"months.november": "\u5341\u4e00\u6708",
"months.october": "\u5341\u6708",
"months.september": "\u4e5d\u6708",
"more": "更多",
"move": "移動",
"name": "名稱",
"next": "下一步",
"night": "夜間",
"no": "否",
"off": "關閉",
"on": "開啟",
"open": "開啟",
"open.newWindow": "在新視窗開啟",
"option": "選項",
"options": "多選項",
"options.none": "無可用選項",
"options.all": "顯示全部 {count} 個選項",
"orientation": "方向",
"orientation.landscape": "橫向",
"orientation.portrait": "直向",
"orientation.square": "正方形",
"page": "頁",
"page.blueprint": "此頁面尚未設定藍圖。你可以在 /site/blueprints/pages/{blueprint}.yml 中定義設定內容。",
"page.changeSlug": "\u66f4\u6539\u9801\u9762\u7db2\u5740",
"page.changeSlug.fromTitle": "\u5f9e\u9801\u9762\u6a19\u984c\u8f38\u5165",
"page.changeStatus": "變更狀態",
"page.changeStatus.position": "頁面位置",
"page.changeStatus.select": "選擇狀態",
"page.changeTemplate": "變更樣板",
"page.changeTemplate.notice": "變更樣板可能會導致部分資料遺失,請小心操作",
"page.create": "建立為 {status}",
"page.delete.confirm": "\u78ba\u8a8d\u522a\u9664\u6b64\u9801\u9762\uff1f",
"page.delete.confirm.subpages": "此頁面下仍有子頁面,是否一併刪除?",
"page.delete.confirm.title": "請輸入頁面標題以確認",
"page.duplicate.appendix": "Copy",
"page.duplicate.files": "複製檔案",
"page.duplicate.pages": "複製子頁面",
"page.move": "移動頁面",
"page.sort": "排序頁面",
"page.status": "頁面狀態",
"page.status.draft": "草稿",
"page.status.draft.description": "該頁面處於草稿模式,僅對已登入的編輯者或透過秘密連結可見",
"page.status.listed": "已列出",
"page.status.listed.description": "該頁面對所有人公開",
"page.status.unlisted": "未列出",
"page.status.unlisted.description": "該頁面僅能透過網址存取",
"pages": "頁面",
"pages.delete.confirm.selected": "你確定要刪除所有選取的頁面嗎?",
"pages.empty": "目前沒有任何頁面",
"pages.status.draft": "草稿",
"pages.status.listed": "已列出",
"pages.status.unlisted": "未列出",
"pagination.page": "頁",
"password": "\u5bc6\u78bc",
"paste": "貼上",
"paste.after": "貼在後方",
"paste.success": "已貼上 {count} 個項目!",
"pixel": "像素",
"plugin": "外掛",
"plugins": "外掛列表",
"prev": "上一步",
"preview": "預覽",
"publish": "發佈",
"published": "已發佈",
"remove": "移除",
"rename": "重新命名",
"renew": "重新啟用",
"replace": "\u66f4\u63db",
"replace.with": "取代為…",
"retry": "\u91cd\u8a66",
"revert": "\u653e\u68c4",
"revert.confirm": "你確定要還原變更嗎?",
"role": "\u6b0a\u9650",
"role.admin.description": "具有所有權限,可管理使用者與網站設定",
"role.admin.title": "管理員",
"role.all": "所有角色",
"role.empty": "尚未設定角色",
"role.description.placeholder": "角色描述…",
"role.nobody.description": "無法登入後台的訪客角色",
"role.nobody.title": "訪客",
"save": "\u5132\u5b58",
"saved": "已儲存",
"search": "搜尋",
"searching": "搜尋中…",
"search.min": "請至少輸入 {min} 個字元",
"search.all": "顯示全部 {count} 筆結果",
"search.results.none": "找不到符合的結果",
"section.invalid": "區段無效",
"section.required": "此區段為必填",
"security": "安全性",
"select": "選取",
"server": "伺服器",
"settings": "設定",
"show": "顯示",
"site.blueprint": "網站藍圖",
"size": "大小",
"slug": "\u9801\u9762\u7db2\u5740",
"sort": "排序",
"sort.drag": "拖曳以排序",
"split": "分割",
"stats.empty": "目前沒有統計資料",
"status": "狀態",
"system.info.copy": "複製系統資訊",
"system.info.copied": "系統資訊已複製",
"system.issues.content": "無法寫入 /content 資料夾",
"system.issues.eol.kirby": "你正在使用已終止維護的 Kirby 版本",
"system.issues.eol.plugin": "你安裝的 {plugin} 外掛已達到生命週期終點,將不再收到安全性更新。",
"system.issues.eol.php": "你安裝的 PHP 版本 {release} 已達生命週期終點,將不再收到安全性更新。",
"system.issues.debug": "目前系統處於除錯模式",
"system.issues.git": "專案資料夾中未偵測到 Git 儲存庫",
"system.issues.https": "網站未透過 HTTPS 保護",
"system.issues.kirby": "Kirby 核心檔案可能已變更,請重新安裝",
"system.issues.local": "網站可能仍在本機開發環境中運行",
"system.issues.site": "找不到 site/config.php 或檔案設定錯誤",
"system.issues.vue.compiler": "Vue 樣板編譯器已啟用",
"system.issues.vulnerability.kirby": "你的安裝可能受到以下漏洞影響(嚴重性:{severity}{description}",
"system.issues.vulnerability.plugin": "你的安裝可能受到 {plugin} 外掛中以下漏洞影響(嚴重性:{severity}{description}",
"system.updateStatus": "更新狀態",
"system.updateStatus.error": "無法檢查更新狀態",
"system.updateStatus.not-vulnerable": "目前版本無安全漏洞",
"system.updateStatus.security-update": "有免費的安全性更新版本 {version} 可供下載",
"system.updateStatus.security-upgrade": "有包含安全修正的版本 {version} 可供升級",
"system.updateStatus.unreleased": "目前為尚未正式發佈的版本",
"system.updateStatus.up-to-date": "已是最新版本",
"system.updateStatus.update": "有免費更新版本 {version} 可供下載",
"system.updateStatus.upgrade": "可升級至版本 {version}",
"tel": "電話",
"tel.placeholder": "+49123456789",
"template": "頁面樣板",
"theme": "主題",
"theme.light": "亮色主題",
"theme.dark": "暗色主題",
"theme.automatic": "配合系統預設",
"title": "頁面標題",
"today": "今天",
"toolbar.button.clear": "清除格式",
"toolbar.button.code": "程式碼",
"toolbar.button.bold": "\u7c97\u9ad4",
"toolbar.button.email": "電子郵件",
"toolbar.button.headings": "標題",
"toolbar.button.heading.1": "標題 1",
"toolbar.button.heading.2": "標題 2",
"toolbar.button.heading.3": "標題 3",
"toolbar.button.heading.4": "標題 4",
"toolbar.button.heading.5": "標題 5",
"toolbar.button.heading.6": "標題 6",
"toolbar.button.italic": "\u659c\u9ad4",
"toolbar.button.file": "檔案",
"toolbar.button.file.select": "選擇檔案",
"toolbar.button.file.upload": "上傳檔案",
"toolbar.button.link": "\u9023\u7d50",
"toolbar.button.paragraph": "段落",
"toolbar.button.strike": "刪除線",
"toolbar.button.sub": "下標",
"toolbar.button.sup": "上標",
"toolbar.button.ol": "有序清單",
"toolbar.button.underline": "底線",
"toolbar.button.ul": "無序清單",
"translation.author": "翻譯者",
"translation.direction": "ltr",
"translation.name": "正體中文",
"translation.locale": "zh_TW",
"type": "類型",
"upload": "上傳",
"upload.error.cantMove": "檔案無法移動到目標位置",
"upload.error.cantWrite": "檔案無法寫入磁碟",
"upload.error.default": "上傳時發生未知錯誤",
"upload.error.extension": "副檔名不被允許",
"upload.error.formSize": "檔案超出表單限制的大小",
"upload.error.iniPostSize": "檔案超出 PHP 設定中的最大值",
"upload.error.iniSize": "檔案大小超過上傳限制",
"upload.error.noFile": "沒有選擇任何檔案",
"upload.error.noFiles": "找不到任何可上傳的檔案",
"upload.error.partial": "檔案僅部分上傳",
"upload.error.tmpDir": "找不到暫存資料夾",
"upload.errors": "上傳錯誤",
"upload.progress": "上傳進度",
"url": "網址",
"url.placeholder": "例如https://example.com",
"user": "使用者",
"user.blueprint": "你可以在 /site/blueprints/users/{blueprint}.yml 中為此使用者角色定義額外的區段與表單欄位。",
"user.changeEmail": "變更電子信箱",
"user.changeLanguage": "變更語言",
"user.changeName": "變更名稱",
"user.changePassword": "變更密碼",
"user.changePassword.current": "目前密碼",
"user.changePassword.new": "更改密碼",
"user.changePassword.new.confirm": "確認新密碼",
"user.changeRole": "變更角色",
"user.changeRole.select": "選擇新角色",
"user.create": "新增使用者",
"user.delete": "刪除使用者",
"user.delete.confirm": "你確定要刪除「{email}」嗎?",
"users": "使用者",
"version": "版本",
"version.changes": "版本變更紀錄",
"version.compare": "比較版本",
"version.current": "目前版本",
"version.latest": "最新版本",
"versionInformation": "版本資訊",
"view": "檢視",
"view.account": "\u60a8\u7684\u5e33\u865f",
"view.installation": "\u5b89\u88dd",
"view.languages": "語言管理",
"view.resetPassword": "重設密碼",
"view.site": "網站設定",
"view.system": "系統資訊",
"view.users": "\u4f7f\u7528\u8005",
"welcome": "歡迎使用 Kirby",
"year": "年",
"yes": "是"
}

File diff suppressed because one or more lines are too long

11731
kirby/panel/dist/js/vue.esm.browser.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,137 @@
<?php
namespace Kirby\Api\Controller;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Content\Lock;
use Kirby\Filesystem\F;
use Kirby\Form\Fields;
use Kirby\Form\Form;
/**
* The Changes controller takes care of the request logic
* to save, discard and publish changes.
*
* @package Kirby Api
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Changes
{
/**
* Cleans up legacy lock files. The `discard`, `publish` and `save` actions
* are perfect for this cleanup job. They will be stopped early if
* the lock is still active and otherwise, we can use them to clean
* up outdated .lock files to keep the content folders clean. This
* can be removed as soon as old .lock files should no longer be around.
*
* @todo Remove in 6.0.0
*/
protected static function cleanup(ModelWithContent $model): void
{
F::remove(Lock::legacyFile($model));
}
/**
* Discards unsaved changes by deleting the changes version
*/
public static function discard(ModelWithContent $model): array
{
$model->version('changes')->delete('current');
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
return [
'status' => 'ok'
];
}
/**
* Saves the lastest state of changes first and then publishes them
*/
public static function publish(ModelWithContent $model, array $input): array
{
// save the given changes first
static::save(
model: $model,
input: $input
);
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
// get the changes version
$changes = $model->version('changes');
// if the changes version does not exist, we need to return early
if ($changes->exists('current') === false) {
return [
'status' => 'ok',
];
}
// publish the changes
$changes->publish(
language: 'current'
);
return [
'status' => 'ok'
];
}
/**
* Saves form input in a new or existing `changes` version
*/
public static function save(ModelWithContent $model, array $input): array
{
// Removes the old .lock file when it is no longer needed
// @todo Remove in 6.0.0
static::cleanup($model);
// get the current language
$language = Language::ensure('current');
// create the fields instance for the model
$fields = Fields::for($model, $language);
// get the changes and latest version for the model
$changes = $model->version('changes');
$latest = $model->version('latest');
// get the source version for the existing content
$source = $changes->exists($language) === true ? $changes : $latest;
$content = $source->content($language)->toArray();
// fill in the form values and pass through any values that are not
// defined as fields, such as uuid, title or similar.
$fields->fill(input: $content);
// submit the new values from the request input
$fields->submit(input: $input);
// save the changes
$changes->save(
fields: $fields->toStoredValues(),
language: $language
);
// if the changes are identical to the latest version,
// we can delete the changes version already at this point
if ($changes->isIdentical(version: $latest, language: $language)) {
$changes->delete(
language: $language
);
}
return [
'status' => 'ok'
];
}
}

436
kirby/src/Api/Upload.php Normal file
View file

@ -0,0 +1,436 @@
<?php
namespace Kirby\Api;
use Closure;
use Exception;
use Kirby\Cms\App;
use Kirby\Cms\File;
use Kirby\Cms\FileRules;
use Kirby\Cms\Page;
use Kirby\Exception\DuplicateException;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* The Upload class handles file uploads in the
* context of the API. It adds support for chunked
* uploads.
*
* @package Kirby Api
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
readonly class Upload
{
public function __construct(
protected Api $api,
protected bool $single = true,
protected bool $debug = false
) {
}
/**
* Ensures a clean chunk ID by stripping forbidden characters
*
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
*/
public static function chunkId(string $id): string
{
$id = Str::slug($id, '', 'a-z0-9');
if (strlen($id) < 3) {
throw new InvalidArgumentException(
message: 'Chunk ID must at least be 3 characters long'
);
}
return $id;
}
/**
* Returns the ideal size for a file chunk
*/
public static function chunkSize(): int
{
$max = [
Str::toBytes(ini_get('upload_max_filesize')),
Str::toBytes(ini_get('post_max_size'))
];
// consider cloudflare proxy limit, if detected
if (isset($_SERVER['HTTP_CF_CONNECTING_IP']) === true) {
$max[] = Str::toBytes('100M');
}
// to be sure, only use 95% of the max possible upload size
return (int)floor(min($max) * 0.95);
}
/**
* Clean up tmp directory of stale files
*/
public static function cleanTmpDir(): void
{
foreach (Dir::files($dir = static::tmpDir(), [], true) as $file) {
// remove any file that hasn't been altered
// in the last 24 hours
if (F::modified($file) < time() - 86400) {
F::remove($file);
}
}
// remove tmp directory if completely empty
if (Dir::isEmpty($dir) === true) {
Dir::remove($dir);
}
}
/**
* Throws an exception with the appropriate translated error message
*
* @throws \Exception Any upload error
*/
public static function error(int $error): void
{
// get error messages from translation
$message = [
UPLOAD_ERR_INI_SIZE => I18n::translate('upload.error.iniSize'),
UPLOAD_ERR_FORM_SIZE => I18n::translate('upload.error.formSize'),
UPLOAD_ERR_PARTIAL => I18n::translate('upload.error.partial'),
UPLOAD_ERR_NO_FILE => I18n::translate('upload.error.noFile'),
UPLOAD_ERR_NO_TMP_DIR => I18n::translate('upload.error.tmpDir'),
UPLOAD_ERR_CANT_WRITE => I18n::translate('upload.error.cantWrite'),
UPLOAD_ERR_EXTENSION => I18n::translate('upload.error.extension')
];
throw new Exception(
message: $message[$error] ?? I18n::translate('upload.error.default', 'The file could not be uploaded')
);
}
/**
* Sanitize the filename and extension
* based on the detected mime type
*/
public static function filename(array $upload): string
{
// get the extension of the uploaded file
$extension = F::extension($upload['name']);
// try to detect the correct mime and add the extension
// accordingly. This will avoid .tmp filenames
if (
empty($extension) === true ||
in_array($extension, ['tmp', 'temp'], true) === true
) {
$mime = F::mime($upload['tmp_name']);
$extension = F::mimeToExtension($mime);
$filename = F::name($upload['name']) . '.' . $extension;
return $filename;
}
return basename($upload['name']);
}
/**
* Upload the files and call closure for each file
*
* @throws \Exception Any upload error
*/
public function process(Closure $callback): array
{
$files = $this->api->requestFiles();
$uploads = [];
$errors = [];
static::validateFiles($files);
foreach ($files as $upload) {
if (
isset($upload['tmp_name']) === false &&
is_array($upload) === true
) {
continue;
}
try {
if ($upload['error'] !== 0) {
static::error($upload['error']);
}
$filename = static::filename($upload);
$source = $this->source($upload['tmp_name'], $filename);
// if the file is uploaded in chunks…
if ($this->api->requestHeaders('Upload-Length')) {
$source = $this->processChunk($source, $filename);
}
// apply callback only to complete uploads
// (incomplete chunk request will return empty $source)
$data = match ($source) {
null => null,
default => $callback($source, $filename)
};
$uploads[$upload['name']] = match (true) {
is_object($data) => $this->api->resolve($data)->toArray(),
default => $data
};
} catch (Exception $e) {
$errors[$upload['name']] = $e->getMessage();
// clean up file from system tmp directory
F::unlink($upload['tmp_name']);
}
if ($this->single === true) {
break;
}
}
return static::response($uploads, $errors);
}
/**
* Handle chunked uploads by merging all chunks
* in the tmp directory and only returning the new
* $source path to the tmp file once complete
*
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
* @throws \Kirby\Exception\Exception Chunk offset does not match existing tmp file
* @throws \Kirby\Exception\InvalidArgumentException Too short ID string
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
*/
public function processChunk(
string $source,
string $filename
): string|null {
// ensure the tmp upload directory exists
Dir::make($dir = static::tmpDir());
// create path for file in tmp upload directory;
// prefix with id while file isn't completely uploaded yet
$id = $this->api->requestHeaders('Upload-Id', '');
$id = static::chunkId($id);
$total = (int)$this->api->requestHeaders('Upload-Length');
$filename = basename($filename);
$tmpRoot = $dir . '/' . $id . '-' . $filename;
// validate various aspects of the request
// to ensure the chunk isn't trying to do malicious actions
static::validateChunk(
source: $source,
tmp: $tmpRoot,
total: $total,
offset: $this->api->requestHeaders('Upload-Offset'),
template: $this->api->requestBody('template'),
);
// stream chunk content and append it to partial file
stream_copy_to_stream(
fopen($source, 'r'),
fopen($tmpRoot, 'a')
);
// clear file stat cache so the following call to `F::size`
// really returns the updated file size
clearstatcache();
// if file isn't complete yet, return early
if (F::size($tmpRoot) < $total) {
return null;
}
// remove id from partial filename now the file is complete,
// so we can pass the path from the tmp upload directory
// as new source path for the file back to the API upload method
rename(
$tmpRoot,
$source = $dir . '/' . $filename
);
return $source;
}
/**
* Convert uploads and errors in response array for API response
*/
public static function response(
array $uploads,
array $errors
): array {
if (count($uploads) + count($errors) <= 1) {
if (count($errors) > 0) {
return [
'status' => 'error',
'message' => current($errors)
];
}
return [
'status' => 'ok',
'data' => $uploads ? current($uploads) : null
];
}
if (count($errors) > 0) {
return [
'status' => 'error',
'errors' => $errors
];
}
return [
'status' => 'ok',
'data' => $uploads
];
}
/**
* Move the tmp file to a location including the extension,
* for better mime detection and return updated source path
*
* @codeCoverageIgnore
*/
public function source(string $source, string $filename): string
{
if ($this->debug === true) {
return $source;
}
$target = dirname($source) . '/' . uniqid() . '.' . $filename;
if (move_uploaded_file($source, $target)) {
return $target;
}
throw new Exception(
message: I18n::translate('upload.error.cantMove')
);
}
/**
* Returns root of directory used for
* temporarily storing (incomplete) uploads
* @codeCoverageIgnore
*/
protected static function tmpDir(): string
{
return App::instance()->root('cache') . '/.uploads';
}
/**
* Ensures the sent chunk is valid
*
* @throws \Kirby\Exception\DuplicateException Duplicate first chunk (same filename and id)
* @throws \Kirby\Exception\InvalidArgumentException Chunk offset does not match existing tmp file
* @throws \Kirby\Exception\InvalidArgumentException The maximum file size for this blueprint was exceeded
* @throws \Kirby\Exception\NotFoundException Subsequent chunk has no existing tmp file
*/
protected static function validateChunk(
string $source,
string $tmp,
int $total,
int $offset,
string|null $template = null
): void {
$file = new File([
'parent' => new Page(['slug' => 'tmp']),
'filename' => $filename = basename($tmp),
'template' => $template
]);
// if the blueprint `maxsize` option is set,
// ensure that the total size communicated in the header
// as well as the current tmp size after adding this chunk
// do not exceed the max limit
if (
($max = $file->blueprint()->accept()['maxsize'] ?? null) &&
(
$total > $max ||
(F::size($source) + F::size($tmp)) > $max
)
) {
throw new InvalidArgumentException(
key: 'file.maxsize'
);
}
// validate the first chunk
if ($offset === 0) {
// sent chunk is expected to be the first part,
// but tmp file already exists
if (F::exists($tmp) === true) {
throw new DuplicateException(
message: 'A tmp file upload with the same filename and upload id already exists: ' . $filename
);
}
// validate file (extension, name) for first chunk;
// will also be validate again by `$model->createFile()`
// when completely uploaded
FileRules::validFile($file, false);
// first chunk is valid
return;
}
// validate subsequent chunks:
// no tmp in place
if (F::exists($tmp) === false) {
throw new NotFoundException(
message: 'Chunk offset ' . $offset . ' for non-existing tmp file: ' . $filename
);
}
// sent chunk's offset is not the continuation of the tmp file
if ($offset !== F::size($tmp)) {
throw new InvalidArgumentException(
message: 'Chunk offset ' . $offset . ' does not match the existing tmp upload file size of ' . F::size($tmp)
);
}
}
/**
* Validate the files array for upload
*
* @throws \Exception No files were uploaded
*/
protected static function validateFiles(array $files): void
{
if ($files === []) {
$postMaxSize = Str::toBytes(ini_get('post_max_size'));
$uploadMaxFileSize = Str::toBytes(ini_get('upload_max_filesize'));
// @codeCoverageIgnoreStart
if ($postMaxSize < $uploadMaxFileSize) {
throw new Exception(
message:
I18n::translate(
'upload.error.iniPostSize',
'The uploaded file exceeds the post_max_size directive in php.ini'
)
);
}
// @codeCoverageIgnoreEnd
throw new Exception(
message:
I18n::translate(
'upload.error.noFiles',
'No files were uploaded'
)
);
}
}
}

View file

@ -0,0 +1,160 @@
<?php
namespace Kirby\Cache;
use Kirby\Cms\Helpers;
use Redis;
use Throwable;
/**
* Redis Cache Driver
*
* @package Kirby Cache
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class RedisCache extends Cache
{
/**
* Store for the redis connection
*/
protected Redis $connection;
/**
* Sets all parameters which are needed to connect to Redis
*
* @param array $options 'host' (default: 127.0.0.1)
* 'port' (default: 6379)
*/
public function __construct(array $options = [])
{
$options = [
'host' => '127.0.0.1',
'port' => 6379,
...$options
];
parent::__construct($options);
// available options for the redis driver
$allowed = [
'host',
'port',
'readTimeout',
'connectTimeout',
'persistent',
'auth',
'ssl',
'retryInterval',
'backoff'
];
// filters only redis supported keys
$redisOptions = array_intersect_key($options, array_flip($allowed));
// creates redis connection
$this->connection = new Redis($redisOptions);
// sets the prefix if defined
if ($prefix = $options['prefix'] ?? null) {
$this->connection->setOption(Redis::OPT_PREFIX, rtrim($prefix, '/') . '/');
}
// selects the database if defined
$database = $options['database'] ?? null;
if ($database !== null) {
$this->connection->select($database);
}
}
/**
* Returns the database number
*/
public function databaseNum(): int
{
return $this->connection->getDbNum();
}
/**
* Returns whether the cache is ready to store values
*/
public function enabled(): bool
{
try {
return Helpers::handleErrors(
fn () => $this->connection->ping(),
fn (int $errno, string $errstr) => true,
fn () => false
);
} catch (Throwable) {
return false;
}
}
/**
* Determines if an item exists in the cache
*/
public function exists(string $key): bool
{
return $this->connection->exists($this->key($key)) !== 0;
}
/**
* Removes keys from the database
* and returns whether the operation was successful
*/
public function flush(): bool
{
return $this->connection->flushDB();
}
/**
* The key is not modified, because the prefix is added by the redis driver itself
*/
protected function key(string $key): string
{
return $key;
}
/**
* Removes an item from the cache
* and returns whether the operation was successful
*/
public function remove(string $key): bool
{
return $this->connection->del($this->key($key));
}
/**
* Internal method to retrieve the raw cache value;
* needs to return a Value object or null if not found
*/
public function retrieve(string $key): Value|null
{
$value = $this->connection->get($this->key($key));
return Value::fromJson($value);
}
/**
* Writes an item to the cache for a given number of minutes
* and returns whether the operation was successful
*
* ```php
* // put an item in the cache for 15 minutes
* $cache->set('value', 'my value', 15);
* ```
*/
public function set(string $key, $value, int $minutes = 0): bool
{
$key = $this->key($key);
$value = (new Value($value, $minutes))->toJson();
if ($minutes > 0) {
return $this->connection->setex($key, $minutes * 60, $value);
}
return $this->connection->set($key, $value);
}
}

130
kirby/src/Cms/Events.php Normal file
View file

@ -0,0 +1,130 @@
<?php
namespace Kirby\Cms;
use Closure;
/**
* The `Events` class outsources the logic of
* `App::apply()` and `App::trigger()` methods
* and makes them easier and more predictable to test.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class Events
{
protected int $level = 0;
protected array $processed = [];
public function __construct(
protected App $app
) {
}
/**
* Runs the hook and applies the result to the argument
* specified by the $modify parameter. By default, the
* first argument is modified.
*/
public function apply(
string $name,
array $args = [],
string|null $modify = null
): mixed {
// modify the first argument by default
$modify ??= array_key_first($args);
return $this->process(
$name,
$args,
// update $modify value after each hook callback
fn ($event, $result) => $event->updateArgument($modify, $result),
// return the modified value
fn ($event) => $event->argument($modify)
);
}
/**
* Returns all matching hook handlers for the given event
*/
public function hooks(Event $event): array
{
// get all hooks for the event name
$name = $event->name();
$hooks = $this->app->extensions('hooks') ?? [];
$result = $hooks[$name] ?? [];
// get all hooks for the event name wildcards
foreach ($event->nameWildcards() as $wildcard) {
$result = [
...$result,
...$hooks[$wildcard] ?? []
];
}
return $result;
}
/**
* Runs the hook
*
* @return ($return is null ? void : mixed)
*/
protected function process(
string $name,
array $args,
Closure|null $afterEach = null,
Closure|null $return = null
) {
// create the event object and get all hook callbacks for this event
$event = new Event($name, $args);
$hooks = $this->hooks($event);
$this->level++;
foreach ($hooks as $hook) {
// skip hooks that have already been processed
if (in_array($hook, $this->processed[$name] ?? []) === true) {
continue;
}
// mark the hook as processed, to avoid endless loops
$this->processed[$name][] = $hook;
// bind the Kirby instance to the hook and run it
$result = $event->call($this->app, $hook);
// run the afterEach callback
if ($afterEach !== null) {
$afterEach($event, $result);
}
}
$this->level--;
// reset the protection after the last nesting level has been closed
if ($this->level === 0) {
$this->processed = [];
}
// run the return callback
if ($return !== null) {
return $return($event);
}
}
/**
* Runs the hook without modifying the arguments
*/
public function trigger(
string $name,
array $args = []
): void {
$this->process($name, $args);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Kirby\Cms;
/**
* HasModels
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
trait HasModels
{
/**
* Registry with all custom models
*/
public static array $models = [];
/**
* Adds new models to the registry
* @internal
*/
public static function extendModels(array $models): array
{
return static::$models = [
...static::$models,
...array_change_key_case($models, CASE_LOWER)
];
}
/**
* Creates an object from model if it has been registered
*/
public static function model(string $name, array $props = []): static
{
$name = strtolower($name);
$class = static::$models[$name] ?? null;
$class ??= static::$models['default'] ?? null;
if ($class !== null) {
$object = new $class($props);
if ($object instanceof self) {
return $object;
}
}
return new static($props);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace Kirby\Cms;
/**
* LanguagePermissions
*
* @package Kirby Cms
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LanguagePermissions extends ModelPermissions
{
protected const CATEGORY = 'languages';
protected function canDelete(): bool
{
return $this->model->isDeletable() === true;
}
}

View file

@ -0,0 +1,243 @@
<?php
namespace Kirby\Cms;
use Closure;
use Kirby\Exception\Exception;
/**
* The ModelCommit class is used to commit a given model action
* in the model action classes. It takes care of running
* the `before` and `after` hooks and updating the state
* of the given model.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ModelCommit
{
protected App $kirby;
protected string $prefix;
public function __construct(
protected ModelWithContent $model,
protected string $action
) {
$this->kirby = $this->model->kirby();
$this->prefix = $this->model::CLASS_ALIAS;
}
/**
* Runs the `after` hook and returns the result.
*/
public function after(mixed $state): mixed
{
// run the `after` hook
$arguments = $this->afterHookArguments($state);
$hook = $this->hook('after', $arguments);
// flush the page cache after any model action
$this->kirby->cache('pages')->flush();
return $hook['result'];
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given model action. It's a wrapper around the
* more specific `afterHookArgumentsFor*Actions` methods.
*/
public function afterHookArguments(mixed $state): array
{
return match (true) {
$this->model instanceof File =>
$this->afterHookArgumentsForFileActions($this->model, $this->action, $state),
$this->model instanceof Page =>
$this->afterHookArgumentsForPageActions($this->model, $this->action, $state),
$this->model instanceof Site =>
$this->afterHookArgumentsForSiteActions($this->model, $state),
$this->model instanceof User =>
$this->afterHookArgumentsForUserActions($this->model, $this->action, $state),
default =>
throw new Exception('Invalid model class')
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given page action.
*/
protected function afterHookArgumentsForPageActions(
Page $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'page' => $state
],
'duplicate' => [
'duplicatePage' => $state,
'originalPage' => $model
],
'delete' => [
'status' => $state,
'page' => $model
],
default => [
'newPage' => $state,
'oldPage' => $model
]
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given file action.
*/
protected function afterHookArgumentsForFileActions(
File $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'file' => $state
],
'delete' => [
'status' => $state,
'file' => $model
],
default => [
'newFile' => $state,
'oldFile' => $model
]
};
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given site action.
*/
protected function afterHookArgumentsForSiteActions(
Site $model,
mixed $state
): array {
return [
'newSite' => $state,
'oldSite' => $model
];
}
/**
* Returns the appropriate arguments for the `after` hook
* for the given user action.
*/
protected function afterHookArgumentsForUserActions(
User $model,
string $action,
mixed $state
): array {
return match ($action) {
'create' => [
'user' => $state
],
'delete' => [
'status' => $state,
'user' => $model
],
default => [
'newUser' => $state,
'oldUser' => $model
]
};
}
/**
* Runs the `before` hook and modifies the arguments
*/
public function before(array $arguments): array
{
// check model rules
$this->validate($arguments);
// run the `before` hook
$hook = $this->hook('before', $arguments);
// check model rules again, after the hook got applied
$this->validate($hook['arguments']);
return $hook['arguments'];
}
/**
* Handles the full call of the given action,
* runs the `before` and `after` hooks and updates
* the state of the given model.
*/
public function call(array $arguments, Closure $callback): mixed
{
// run the before hook
$arguments = $this->before($arguments);
// run the commit action
$state = $callback(...array_values($arguments));
// update the state for the after hook
ModelState::update(
method: $this->action,
current: $this->model,
next: $state
);
// run the after hook and return the result
return $this->after($state);
}
/**
* Runs the given hook and modifies the first argument
* of the given arguments array. It returns an array with
* `arguments` and `result` keys.
*/
public function hook(string $hook, array $arguments): array
{
// the very first argument (which should be the model)
// is modified by the return value from the hook (if any returned)
$appliedTo = array_key_first($arguments);
// run the hook and modify the first argument
$arguments[$appliedTo] = $this->kirby->apply(
// e.g. page.create:before
$this->prefix . '.' . $this->action . ':' . $hook,
$arguments,
$appliedTo
);
return [
'arguments' => $arguments,
'result' => $arguments[$appliedTo],
];
}
/**
* Checks the model rules for the given action
* if there's a matching rule method.
*/
public function validate(array $arguments): void
{
$rules = match (true) {
$this->model instanceof File => FileRules::class,
$this->model instanceof Page => PageRules::class,
$this->model instanceof Site => SiteRules::class,
$this->model instanceof User => UserRules::class,
default => throw new Exception('Invalid model class') // @codeCoverageIgnore
};
if (method_exists($rules, $this->action) === true) {
$rules::{$this->action}(...array_values($arguments));
}
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Kirby\Cms;
/**
* The ModelState class is used to update app-wide model states.
* It's mainly used in the `ModelCommit` class to update the
* state of the given model after the action has been
* executed.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ModelState
{
/**
* Updates the state of the given model.
*/
public static function update(
string $method,
ModelWithContent $current,
ModelWithContent|bool|null $next = null,
ModelWithContent|Site|null $parent = null
): void {
// normalize the method
$method = match ($method) {
'append', 'create' => 'append',
'remove', 'delete' => 'remove',
'duplicate' => false, // The models need to take care of this
default => 'update'
};
if ($method === false) {
return;
}
match (true) {
$current instanceof File => static::updateFile($method, $current, $next),
$current instanceof Page => static::updatePage($method, $current, $next, $parent),
$current instanceof Site => static::updateSite($current, $next),
$current instanceof User => static::updateUser($method, $current, $next),
};
}
/**
* Updates the state of the given file.
*/
protected static function updateFile(
string $method,
File $current,
File|bool|null $next = null
): void {
$next = $next instanceof File ? $next : $current;
// update the files collection
$next->parent()->files()->$method($next);
}
/**
* Updates the state of the given page.
*/
protected static function updatePage(
string $method,
Page $current,
Page|bool|null $next = null,
Page|Site|null $parent = null
): void {
$next = $next instanceof Page ? $next : $current;
$parent ??= $next->parentModel();
if ($next->isDraft() === true) {
$parent->drafts()->$method($next);
} else {
$parent->children()->$method($next);
}
// update the childrenAndDrafts() cache
$parent->childrenAndDrafts()->$method($next);
}
/**
* Updates the state of the given site.
*/
protected static function updateSite(
Site $current,
Site|null $next = null
): void {
App::instance()->setSite($next ?? $current);
}
/**
* Updates the state of the given user.
*/
protected static function updateUser(
string $method,
User $current,
User|bool|null $next = null
): void {
$next = $next instanceof User ? $next : $current;
// update the users collection
App::instance()->users()->$method($next);
}
}

236
kirby/src/Cms/PageCopy.php Normal file
View file

@ -0,0 +1,236 @@
<?php
namespace Kirby\Cms;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
/**
* Normalizes a newly generated copy of a page,
* adapting page slugs, UUIDs etc.
* (for single as well as multilang setups)
*
* @package Kirby Cms
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PageCopy
{
public function __construct(
public Page $copy,
public Page|null $original = null,
public bool $withFiles = false,
public bool $withChildren = false,
public array $uuids = []
) {
}
/**
* Converts UUIDs for copied pages,
* replacing the old UUID with a newly generated one
* for all newly generated pages and files
*/
public function convertUuids(Language|null $language): void
{
if (Uuids::enabled() === false) {
return;
}
if (
$language instanceof Language &&
$language->isDefault() === false
) {
return;
}
// store old UUID
$old = $this->copy->uuid()->toString();
// re-generate UUID for the page
$this->copy = $this->copy->save(
['uuid' => Uuid::generate()],
$language?->code()
);
// track UUID change
$this->uuids[$old] = $this->copy->uuid()->toString();
$this->convertFileUuids($language);
$this->convertChildrenUuids($language);
}
/**
* Re-generate UUIDs for each child recursively
* and merge with the tracked changed UUIDs
*/
protected function convertChildrenUuids(Language|null $language): void
{
// re-generate UUIDs and track changes
if ($this->withChildren === true) {
foreach ($this->copy->childrenAndDrafts() as $child) {
// always adapt files of subpages as they are
// currently always copied; adapt children recursively
$child = new PageCopy(
$child,
withChildren: true,
withFiles: true,
uuids: $this->uuids
);
$child->convertUuids($language);
$this->uuids = [...$this->uuids, ...$child->uuids];
}
}
// if children have not been copied over,
// track all children UUIDs from original page to
// remove/replace with empty string
if ($this->withChildren === false) {
foreach ($this->original?->index(drafts: true) ?? [] as $child) {
$this->uuids[$child->uuid()->toString()] = '';
foreach ($child->files() as $file) {
$this->uuids[$file->uuid()->toString()] = '';
}
}
}
}
/**
* Re-generate UUID for each file and track the change
*/
protected function convertFileUuids(Language|null $language): void
{
// re-generate UUIDs and track changes
if ($this->withFiles === true) {
foreach ($this->copy->files() as $file) {
// store old file UUID
$old = $file->uuid()->toString();
// re-generate UUID for the file
$file = $file->save(
['uuid' => Uuid::generate()],
$language?->code()
);
// track UUID change
$this->uuids[$old] = $file->uuid()->toString();
}
}
// if files have not been copied over,
// track file UUIDs from original page to
// remove/replace with empty string
if ($this->withFiles === false) {
foreach ($this->original?->files() ?? [] as $file) {
$this->uuids[$file->uuid()->toString()] = '';
}
}
}
/**
* Returns all languages to adapt
*
* @todo Refactor once singe-lang mode also works with a language object
*/
public function languages(): Languages|iterable
{
$kirby = App::instance();
if ($kirby->multilang() === true) {
return $kirby->languages();
}
return [null];
}
/**
* Processes the copy with all necessary adaptations.
* Main method to use if not familiar with individual steps.
*/
public static function process(
Page $copy,
Page|null $original = null,
bool $withFiles = false,
bool $withChildren = false
): Page {
$converter = new static($copy, $original, $withFiles, $withChildren);
// loop through all languages to remove slug from non-default
// languages and re-generate UUIDs (and track changes)
foreach ($converter->languages() as $language) {
$converter->removeSlug($language);
$converter->convertUuids($language);
}
// apply all tracked UUID changes at once
$converter->replaceUuids();
return $converter->copy;
}
/**
* Removes translated slug for copied page.
* This is needed to avoid translated slug
* collisions with the original page.
*/
public function removeSlug(Language|null $language): void
{
// single lang setup
if ($language === null) {
return;
}
// don't remove slug from default language
if ($language->isDefault() === true) {
return;
}
if ($this->copy->translation($language)->exists() === true) {
$this->copy = $this->copy->save(
['slug' => null],
$language->code()
);
}
}
/**
* Replace old UUIDs with new UUIDs in the content
*/
public function replaceUuids(): void
{
if (Uuids::enabled() === false) {
return;
}
foreach ($this->copy->storage()->all() as $versionId => $language) {
$this->copy->storage()->replaceStrings(
$versionId,
$language,
$this->uuids
);
}
if ($this->withFiles === true) {
foreach ($this->copy->files() as $file) {
foreach ($file->storage()->all() as $versionId => $language) {
$file->storage()->replaceStrings(
$versionId,
$language,
$this->uuids
);
}
}
}
if ($this->withChildren === true) {
foreach ($this->copy->childrenAndDrafts() as $child) {
$child = new PageCopy($child, withFiles: true, withChildren: true, uuids: $this->uuids);
$child->replaceUuids();
}
}
}
}

View file

@ -0,0 +1,197 @@
<?php
namespace Kirby\Content;
use Kirby\Cache\Cache;
use Kirby\Cms\App;
use Kirby\Cms\Files;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Pages;
use Kirby\Cms\Users;
use Kirby\Toolkit\A;
/**
* The Changes class tracks changed models
* in the Site's changes field.
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Changes
{
protected App $kirby;
public function __construct()
{
$this->kirby = App::instance();
}
/**
* Access helper for the cache, in which changes are stored
*/
public function cache(): Cache
{
return $this->kirby->cache('changes');
}
/**
* Returns whether the cache has been populated
*/
public function cacheExists(): bool
{
return $this->cache()->get('__updated__') !== null;
}
/**
* Returns the cache key for a given model
*/
public function cacheKey(ModelWithContent $model): string
{
return $model::CLASS_ALIAS . 's';
}
/**
* Verify that the tracked model still really has changes.
* If not, untrack and remove from collection.
*
* @template T of \Kirby\Cms\Files|\Kirby\Cms\Pages|\Kirby\Cms\Users
* @param T $tracked
* @return T
*/
public function ensure(Files|Pages|Users $tracked): Files|Pages|Users
{
foreach ($tracked as $model) {
if ($model->version('changes')->exists('*') === false) {
$this->untrack($model);
$tracked->remove($model);
}
}
return $tracked;
}
/**
* Return all files with unsaved changes
*/
public function files(): Files
{
$files = new Files([]);
foreach ($this->read('files') as $id) {
if ($file = $this->kirby->file($id)) {
$files->add($file);
}
}
return $this->ensure($files);
}
/**
* Rebuilds the cache by finding all models with changes version
*/
public function generateCache(): void
{
$models = [
'files' => [],
'pages' => [],
'users' => []
];
foreach ($this->kirby->models() as $model) {
if ($model->version('changes')->exists('*') === true) {
$models[$this->cacheKey($model)][] = (string)($model->uuid() ?? $model->id());
}
}
foreach ($models as $key => $changes) {
$this->update($key, $changes);
}
}
/**
* Return all pages with unsaved changes
*/
public function pages(): Pages
{
/**
* @var \Kirby\Cms\Pages $pages
*/
$pages = $this->kirby->site()->find(
false,
false,
...$this->read('pages')
);
return $this->ensure($pages);
}
/**
* Read the changes for a given model type
*/
public function read(string $key): array
{
return $this->cache()->get($key) ?? [];
}
/**
* Add a new model to the list of unsaved changes
*/
public function track(ModelWithContent $model): void
{
$key = $this->cacheKey($model);
$changes = $this->read($key);
$changes[] = (string)($model->uuid() ?? $model->id());
$this->update($key, $changes);
}
/**
* Remove a model from the list of unsaved changes
*/
public function untrack(ModelWithContent $model): void
{
// get the cache key for the model type
$key = $this->cacheKey($model);
// remove the model from the list of changes
$changes = A::filter(
$this->read($key),
fn ($id) => $id !== (string)($model->uuid() ?? $model->id())
);
$this->update($key, $changes);
}
/**
* Update the changes field
*/
public function update(string $key, array $changes): void
{
$changes = array_unique($changes);
$changes = array_values($changes);
$this->cache()->set($key, $changes);
$this->cache()->set('__updated__', time());
}
/**
* Return all users with unsaved changes
*/
public function users(): Users
{
/**
* @var \Kirby\Cms\Users $users
*/
$users = $this->kirby->users()->find(
false,
false,
...$this->read('users')
);
return $this->ensure($users);
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Exception\LogicException;
/**
* @package Kirby Content
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class ImmutableMemoryStorage extends MemoryStorage
{
public function __construct(
protected ModelWithContent $model,
protected ModelWithContent|null $nextModel = null
) {
parent::__construct($model);
}
/**
* Immutable storage entries cannot be deleted
*
* @throws \Kirby\Exception\LogicException
*/
public function delete(VersionId $versionId, Language $language): void
{
$this->preventMutation('deleted');
}
/**
* Immutable storage entries cannot be moved
*
* @throws \Kirby\Exception\LogicException
*/
public function move(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
): void {
$this->preventMutation('moved');
}
/**
* Returns the next state of the model if the
* reference is given
*/
public function nextModel(): ModelWithContent|null
{
return $this->nextModel;
}
/**
* Throws an exception to avoid the mutation of storage data
*
* @throws \Kirby\Exception\LogicException
*/
protected function preventMutation(string $mutation): void
{
throw new LogicException(
message: 'Storage for the ' . $this->model::CLASS_ALIAS . ' is immutable and cannot be ' . $mutation . '. Make sure to use the last alteration of the object.'
);
}
/**
* Immutable storage entries cannot be touched
*
* @throws \Kirby\Exception\LogicException
*/
public function touch(VersionId $versionId, Language $language): void
{
$this->preventMutation('touched');
}
/**
* Immutable storage entries cannot be updated
*
* @throws \Kirby\Exception\LogicException
*/
public function update(VersionId $versionId, Language $language, array $fields): void
{
$this->preventMutation('updated');
}
}

229
kirby/src/Content/Lock.php Normal file
View file

@ -0,0 +1,229 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\User;
use Kirby\Data\Data;
use Kirby\Toolkit\Str;
/**
* The Lock class provides information about the
* locking state of a content version, depending
* on the timestamp and locked user id
*
* @since 5.0.0
* @unstable
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Lock
{
public function __construct(
protected User|null $user = null,
protected int|null $modified = null,
protected bool $legacy = false
) {
}
/**
* Creates a lock for the given version by
* reading the modification timestamp and
* lock user id from the version.
*/
public static function for(
Version $version,
Language|string $language = 'default'
): static {
if ($legacy = static::legacy($version->model())) {
return $legacy;
}
// wildcard to search for a lock in any language
// the first locked one will be preferred
if ($language === '*') {
foreach (Languages::ensure() as $language) {
$lock = static::for($version, $language);
// return the first locked lock if any exists
if ($lock->isLocked() === true) {
return $lock;
}
}
// return the last lock if no lock was found
return $lock;
}
$language = Language::ensure($language);
// if the version does not exist, it cannot be locked
if ($version->exists($language) === false) {
// create an open lock for the current user
return new static(
user: App::instance()->user(),
);
}
// Read the locked user id from the version
if ($userId = ($version->read($language)['lock'] ?? null)) {
$user = App::instance()->user($userId);
}
return new static(
user: $user ?? null,
modified: $version->modified($language)
);
}
/**
* Checks if the lock is still active because
* recent changes have been made to the content
*/
public function isActive(): bool
{
$minutes = 10;
return $this->modified > time() - (60 * $minutes);
}
/**
* Checks if content locking is enabled at all
*/
public static function isEnabled(): bool
{
return App::instance()->option('content.locking', true) !== false;
}
/**
* Checks if the lock is coming from an old .lock file
*/
public function isLegacy(): bool
{
return $this->legacy;
}
/**
* Checks if the lock is actually locked
*/
public function isLocked(): bool
{
// if locking is disabled globally,
// the lock is always open
if (static::isEnabled() === false) {
return false;
}
if ($this->user === null) {
return false;
}
// the version is not locked if the editing user
// is the currently logged in user
if ($this->user === App::instance()->user()) {
return false;
}
// check if the lock is still active due to the
// content currently being edited.
if ($this->isActive() === false) {
return false;
}
return true;
}
/**
* Looks for old .lock files and tries to create a
* usable lock instance from them
*/
public static function legacy(ModelWithContent $model): static|null
{
$kirby = $model->kirby();
$file = static::legacyFile($model);
$id = '/' . $model->id();
// no legacy lock file? no lock.
if (file_exists($file) === false) {
return null;
}
$data = Data::read($file, 'yml', fail: false)[$id] ?? [];
// no valid lock entry? no lock.
if (isset($data['lock']) === false) {
return null;
}
// has the lock been unlocked? no lock.
if (isset($data['unlock']) === true) {
return null;
}
return new static(
user: $kirby->user($data['lock']['user']),
modified: $data['lock']['time'],
legacy: true
);
}
/**
* Returns the absolute path to a legacy lock file
*/
public static function legacyFile(ModelWithContent $model): string
{
$root = match ($model::CLASS_ALIAS) {
'file' => dirname($model->root()),
default => $model->root()
};
return $root . '/.lock';
}
/**
* Returns the timestamp when the locked content has
* been updated. You can pass a format to get a useful,
* formatted date back.
*/
public function modified(
string|null $format = null,
string|null $handler = null
): int|string|false|null {
if ($this->modified === null) {
return null;
}
return Str::date($this->modified, $format, $handler);
}
/**
* Converts the lock info to an array. This is directly
* usable for Panel view props.
*/
public function toArray(): array
{
return [
'isLegacy' => $this->isLegacy(),
'isLocked' => $this->isLocked(),
'modified' => $this->modified('c', 'date'),
'user' => [
'id' => $this->user?->id(),
'email' => $this->user?->email()
]
];
}
/**
* Returns the user to whom this lock belongs
*/
public function user(): User|null
{
return $this->user;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Kirby\Content;
use Kirby\Exception\LogicException;
/**
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class LockedContentException extends LogicException
{
protected static string $defaultKey = 'content.lock';
protected static string $defaultFallback = 'The version is locked';
protected static int $defaultHttpCode = 423;
public function __construct(
Lock $lock,
string|null $key = null,
string|null $message = null,
) {
parent::__construct(
message: $message,
key: $key,
details: $lock->toArray()
);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Kirby\Content;
use Kirby\Cache\MemoryCache;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
/**
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class MemoryStorage extends Storage
{
/**
* Cache instance, used to store content in memory
*/
protected MemoryCache $cache;
/**
* Sets up the cache instance
*/
public function __construct(protected ModelWithContent $model)
{
parent::__construct($model);
$this->cache = new MemoryCache();
}
/**
* Returns a unique id for a combination
* of the version id, the language code and the model id
*/
protected function cacheId(VersionId $versionId, Language $language): string
{
return $versionId->value() . '/' . $language->code() . '/' . $this->model->id() . '/' . spl_object_hash($this->model);
}
/**
* Deletes an existing version in an idempotent way if it was already deleted
*/
public function delete(VersionId $versionId, Language $language): void
{
$this->cache->remove($this->cacheId($versionId, $language));
}
/**
* Checks if a version exists
*/
public function exists(VersionId $versionId, Language $language): bool
{
return $this->cache->exists($this->cacheId($versionId, $language));
}
/**
* Returns the modification timestamp of a version if it exists
*/
public function modified(VersionId $versionId, Language $language): int|null
{
if ($this->exists($versionId, $language) === false) {
return null;
}
return $this->cache->modified($this->cacheId($versionId, $language));
}
/**
* Returns the stored content fields
*
* @return array<string, string>
*/
public function read(VersionId $versionId, Language $language): array
{
return $this->cache->get($this->cacheId($versionId, $language)) ?? [];
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function touch(VersionId $versionId, Language $language): void
{
$fields = $this->read($versionId, $language);
$this->write($versionId, $language, $fields);
}
/**
* Writes the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*/
protected function write(VersionId $versionId, Language $language, array $fields): void
{
$this->cache->set($this->cacheId($versionId, $language), $fields);
}
}

View file

@ -0,0 +1,331 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\File;
use Kirby\Cms\Language;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Cms\User;
use Kirby\Data\Data;
use Kirby\Exception\Exception;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
/**
* Content storage handler using plain text files
* stored in the content folder
*
* @package Kirby Content
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 4.0.0
* @unstable
*/
class PlainTextStorage extends Storage
{
/**
* Creates the absolute directory path for the model
*/
protected function contentDirectory(VersionId $versionId): string
{
$directory = match (true) {
$this->model instanceof File
=> dirname($this->model->root()),
default
=> $this->model->root()
};
if ($versionId->is('changes')) {
$directory .= '/_changes';
}
return $directory;
}
/**
* Returns the absolute path to the content file
* @internal To be made `protected` when the CMS core no longer relies on it
*/
public function contentFile(VersionId $versionId, Language $language): string
{
// get the filename without extension and language code
return match (true) {
$this->model instanceof File => $this->contentFileForFile($this->model, $versionId, $language),
$this->model instanceof Page => $this->contentFileForPage($this->model, $versionId, $language),
$this->model instanceof Site => $this->contentFileForSite($this->model, $versionId, $language),
$this->model instanceof User => $this->contentFileForUser($this->model, $versionId, $language),
// @codeCoverageIgnoreStart
default => throw new LogicException(
message: 'Cannot determine content file for model type "' . $this->model::CLASS_ALIAS . '"'
)
// @codeCoverageIgnoreEnd
};
}
/**
* Returns the absolute path to the content file of a file model
*/
protected function contentFileForFile(File $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->filename(), $language);
}
/**
* Returns the absolute path to the content file of a page model
*/
protected function contentFileForPage(Page $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename($model->intendedTemplate()->name(), $language);
}
/**
* Returns the absolute path to the content file of a site model
*/
protected function contentFileForSite(Site $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename('site', $language);
}
/**
* Returns the absolute path to the content file of a user model
*/
protected function contentFileForUser(User $model, VersionId $versionId, Language $language): string
{
return $this->contentDirectory($versionId) . '/' . $this->contentFilename('user', $language);
}
/**
* Creates a filename with extension and optional language code
* in a multi-language installation
*/
protected function contentFilename(string $name, Language $language): string
{
$kirby = $this->model->kirby();
$extension = $kirby->contentExtension();
if ($language->isSingle() === false) {
return $name . '.' . $language->code() . '.' . $extension;
}
return $name . '.' . $extension;
}
/**
* Returns an array with content files of all languages
* @internal To be made `protected` when the CMS core no longer relies on it
*/
public function contentFiles(VersionId $versionId): array
{
if ($this->model->kirby()->multilang() === true) {
return $this->model->kirby()->languages()->values(
fn ($language) => $this->contentFile($versionId, $language)
);
}
return [
$this->contentFile($versionId, Language::single())
];
}
/**
* Deletes an existing version in an idempotent way if it was already deleted
*/
public function delete(VersionId $versionId, Language $language): void
{
$contentFile = $this->contentFile($versionId, $language);
// @codeCoverageIgnoreStart
if (F::unlink($contentFile) !== true) {
throw new Exception(message: 'Could not delete content file');
}
// @codeCoverageIgnoreEnd
$contentDirectory = $this->contentDirectory($versionId);
// clean up empty content directories (_changes or the page/user directory)
$this->deleteEmptyDirectory($contentDirectory);
// delete empty _drafts directories for pages
if (
$versionId->is('latest') === true &&
$this->model instanceof Page &&
$this->model->isDraft() === true
) {
$this->deleteEmptyDirectory(dirname($contentDirectory));
}
}
/**
* Helper to delete empty _changes directories
*
* @throws \Kirby\Exception\Exception if the directory cannot be deleted
*/
protected function deleteEmptyDirectory(string $directory): void
{
if (
Dir::exists($directory) === true &&
Dir::isEmpty($directory) === true
) {
// @codeCoverageIgnoreStart
if (Dir::remove($directory) !== true) {
throw new Exception(
message: 'Could not delete empty content directory'
);
}
// @codeCoverageIgnoreEnd
}
}
/**
* Checks if a version exists
*/
public function exists(VersionId $versionId, Language $language): bool
{
$contentFile = $this->contentFile($versionId, $language);
// The version definitely exists, if there's a
// matching content file
if (file_exists($contentFile) === true) {
return true;
}
// A changed version or non-default language version does not exist
// if the content file was not found
if (
$versionId->is('latest') === false ||
$language->isDefault() === false
) {
return false;
}
// Whether the default version exists,
// depends on different cases for each model.
// Page, Site and User exist as soon as the folder is there.
// A File exists as soon as the file is there.
return match (true) {
$this->model instanceof File => is_file($this->model->root()) === true,
$this->model instanceof Page,
$this->model instanceof Site,
$this->model instanceof User => is_dir($this->model->root()) === true,
// @codeCoverageIgnoreStart
default => throw new LogicException(
message: 'Cannot determine existence for model type "' . $this->model::CLASS_ALIAS . '"'
)
// @codeCoverageIgnoreEnd
};
}
/**
* Compare two version-language-storage combinations
*/
public function isSameStorageLocation(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
) {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
// no need to compare content files if the new
// storage type is different
if ($toStorage instanceof self === false) {
return false;
}
$contentFileA = $this->contentFile($fromVersionId, $fromLanguage);
$contentFileB = $toStorage->contentFile($toVersionId, $toLanguage);
return $contentFileA === $contentFileB;
}
/**
* Returns the modification timestamp of a version
* if it exists
*/
public function modified(VersionId $versionId, Language $language): int|null
{
$modified = F::modified($this->contentFile($versionId, $language));
if (is_int($modified) === true) {
return $modified;
}
return null;
}
/**
* Returns the stored content fields
*
* @return array<string, string>
*/
public function read(VersionId $versionId, Language $language): array
{
$contentFile = $this->contentFile($versionId, $language);
if (file_exists($contentFile) === true) {
return Data::read($contentFile);
}
// For existing versions that don't have a content file yet,
// we can safely return an empty array that can be filled later.
// This might be the case for pages that only have a directory
// so far, or for files that don't have any metadata yet.
return [];
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\Exception If the file cannot be touched
*/
public function touch(VersionId $versionId, Language $language): void
{
$success = touch($this->contentFile($versionId, $language));
// @codeCoverageIgnoreStart
if ($success !== true) {
throw new Exception(
message: 'Could not touch existing content file'
);
}
// @codeCoverageIgnoreEnd
}
/**
* Writes the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\Exception If the content cannot be written
*/
protected function write(VersionId $versionId, Language $language, array $fields): void
{
// only store non-null value fields
$fields = array_filter($fields, fn ($field) => $field !== null);
// Content for files is only stored when there are any fields.
// Otherwise, the storage handler will take care here of cleaning up
// unnecessary content files.
if ($this->model instanceof File && $fields === []) {
$this->delete($versionId, $language);
return;
}
$success = Data::write($this->contentFile($versionId, $language), $fields);
// @codeCoverageIgnoreStart
if ($success !== true) {
throw new Exception(message: 'Could not write the content file');
}
// @codeCoverageIgnoreEnd
}
}

View file

@ -0,0 +1,325 @@
<?php
namespace Kirby\Content;
use Generator;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\A;
/**
* Abstract for content storage handlers;
* note that it is so far not viable to build custom
* handlers because the CMS core relies on the filesystem
* and cannot fully benefit from this abstraction yet
*
* @package Kirby Content
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 4.0.0
* @unstable
*/
abstract class Storage
{
public function __construct(protected ModelWithContent $model)
{
}
/**
* Returns generator for all existing version-language combinations
*
* @return Generator<\Kirby\Content\VersionId, \Kirby\Cms\Language>
*/
public function all(): Generator
{
foreach (Languages::ensure() as $language) {
foreach ($this->model->versions() as $version) {
if ($this->exists($version->id(), $language) === true) {
yield $version->id() => $language;
}
}
}
}
/**
* Copies content from one version-language combination to another
*/
public function copy(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
): void {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
// don't copy content to the same version-language-storage combination
if ($this->isSameStorageLocation(
fromVersionId: $fromVersionId,
fromLanguage: $fromLanguage,
toVersionId: $toVersionId,
toLanguage: $toLanguage,
toStorage: $toStorage
)) {
return;
}
// read the existing fields
$content = $this->read($fromVersionId, $fromLanguage);
// create the new version
$toStorage->create($toVersionId, $toLanguage, $content);
}
/**
* Copies all content to another storage
*/
public function copyAll(Storage $to): void
{
foreach ($this->all() as $versionId => $language) {
$this->copy($versionId, $language, toStorage: $to);
}
}
/**
* Creates a new version
*
* @param array<string, string> $fields Content fields
*/
public function create(VersionId $versionId, Language $language, array $fields): void
{
$this->write($versionId, $language, $fields);
}
/**
* Deletes an existing version in an idempotent way if it was already deleted
*/
abstract public function delete(VersionId $versionId, Language $language): void;
/**
* Deletes all versions when deleting a language
* @unstable
* @todo Move to `Language` class
*/
public function deleteLanguage(Language $language): void
{
foreach ($this->model->versions() as $version) {
$this->delete($version->id(), $language);
}
}
/**
* Checks if a version exists
*/
abstract public function exists(VersionId $versionId, Language $language): bool;
/**
* Creates a new storage instance with all the versions
* from the given storage instance.
*/
public static function from(self $fromStorage): static
{
$toStorage = new static(
model: $fromStorage->model()
);
// copy all versions from the given storage instance
// and add them to the new storage instance.
$fromStorage->copyAll($toStorage);
return $toStorage;
}
/**
* Compare two version-language-storage combinations
*/
public function isSameStorageLocation(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
) {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
if (
$fromVersionId->is($toVersionId) &&
$fromLanguage->is($toLanguage) &&
$this === $toStorage
) {
return true;
}
return false;
}
/**
* Returns the related model
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* Returns the modification timestamp of a version if it exists
*/
abstract public function modified(VersionId $versionId, Language $language): int|null;
/**
* Moves content from one version-language combination to another
*/
public function move(
VersionId $fromVersionId,
Language $fromLanguage,
VersionId|null $toVersionId = null,
Language|null $toLanguage = null,
Storage|null $toStorage = null
): void {
// fallbacks to allow keeping the method call lean
$toVersionId ??= $fromVersionId;
$toLanguage ??= $fromLanguage;
$toStorage ??= $this;
// don't move content to the same version-language-storage combination
if ($this->isSameStorageLocation(
fromVersionId: $fromVersionId,
fromLanguage: $fromLanguage,
toVersionId: $toVersionId,
toLanguage: $toLanguage,
toStorage: $toStorage
)) {
return;
}
// copy content to new version
$this->copy(
$fromVersionId,
$fromLanguage,
$toVersionId,
$toLanguage,
$toStorage
);
// clean up the old version
$this->delete($fromVersionId, $fromLanguage);
}
/**
* Moves all content to another storage
*/
public function moveAll(Storage $to): void
{
foreach ($this->all() as $versionId => $language) {
$this->move($versionId, $language, toStorage: $to);
}
}
/**
* Adapts all versions when converting languages
* @unstable
* @todo Move to `Language` class
*/
public function moveLanguage(
Language $fromLanguage,
Language $toLanguage
): void {
foreach ($this->model->versions() as $version) {
if ($this->exists($version->id(), $fromLanguage) === true) {
$this->move(
$version->id(),
$fromLanguage,
toLanguage: $toLanguage
);
}
}
}
/**
* Returns the stored content fields
*
* @return array<string, string>
*/
abstract public function read(VersionId $versionId, Language $language): array;
/**
* Searches and replaces one or multiple strings
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function replaceStrings(
VersionId $versionId,
Language $language,
array $map
): void {
$fields = $this->read($versionId, $language);
$fields = A::map(
$fields,
function ($value) use ($map) {
// skip fields with null values
if ($value === null) {
return null;
}
return str_replace(
array_keys($map),
array_values($map),
$value
);
}
);
$this->update($versionId, $language, $fields);
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
abstract public function touch(VersionId $versionId, Language $language): void;
/**
* Touches all versions of a language
* @unstable
* @todo Move to `Language` class
*/
public function touchLanguage(Language $language): void
{
foreach ($this->model->versions() as $version) {
if ($this->exists($version->id(), $language) === true) {
$this->touch($version->id(), $language);
}
}
}
/**
* Updates the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\Exception If the file cannot be written
*/
public function update(VersionId $versionId, Language $language, array $fields): void
{
$this->write($versionId, $language, $fields);
}
/**
* Writes the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\Exception If the content cannot be written
*/
abstract protected function write(VersionId $versionId, Language $language, array $fields): void;
}

View file

@ -0,0 +1,191 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Helpers;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Exception\Exception;
/**
* Each page, file or site can have multiple
* translated versions of their content,
* represented by this class
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Translation
{
/**
* Creates a new translation object
*/
public function __construct(
protected ModelWithContent $model,
protected Version $version,
protected Language $language
) {
}
/**
* Improve `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Returns the language code of the
* translation
*/
public function code(): string
{
return $this->language->code();
}
/**
* Returns the translation content
* as plain array
*/
public function content(): array
{
return $this->version->content($this->language)->toArray();
}
/**
* Absolute path to the translation content file
*
* @deprecated 5.0.0
*/
public function contentFile(): string
{
Helpers::deprecated('`$translation->contentFile()` has been deprecated. Please let us know if you have a use case for a replacement.', 'translation-methods');
return $this->version->contentFile($this->language);
}
/**
* Creates a new Translation for the given model
*
* @todo Needs to be refactored as soon as Version::create becomes static
* (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
*/
public static function create(
ModelWithContent $model,
Version $version,
Language $language,
array $fields,
string|null $slug = null
): static {
// add the custom slug to the fields array
if ($slug !== null) {
$fields['slug'] = $slug;
}
$version->save($fields, $language);
return new static(
model: $model,
version: $version,
language: $language,
);
}
/**
* Checks if the translation file exists
*/
public function exists(): bool
{
return $this->version->exists($this->language);
}
/**
* Returns the translation code as id
*/
public function id(): string
{
return $this->language->code();
}
/**
* Checks if the this is the default translation
* of the model
*
* @deprecated 5.0.0 Use `::language()->isDefault()` instead
*/
public function isDefault(): bool
{
Helpers::deprecated('`$translation->isDefault()` has been deprecated. Use `$translation->language()->isDefault()` instead.', 'translation-methods');
return $this->language->isDefault();
}
/**
* Returns the language
*/
public function language(): Language
{
return $this->language;
}
/**
* Returns the parent page, file or site object
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* @deprecated 5.0.0 Use `$translation->model()` instead
*/
public function parent(): ModelWithContent
{
throw new Exception(
message: '`$translation->parent()` has been deprecated. Please use `$translation->model()` instead'
);
}
/**
* Returns the custom translation slug
*/
public function slug(): string|null
{
return $this->version->read($this->language)['slug'] ?? null;
}
/**
* Converts the most important translation
* props to an array
*/
public function toArray(): array
{
return [
'code' => $this->language->code(),
'content' => $this->content(),
'exists' => $this->exists(),
'slug' => $this->slug(),
];
}
/**
* @deprecated 5.0.0 Use `$model->version()->update()` instead
*/
public function update(array|null $data = null, bool $overwrite = false): static
{
throw new Exception(
message: '`$translation->update()` has been deprecated. Please use `$model->version()->update()` instead'
);
}
/**
* Returns the version
*/
public function version(): Version
{
return $this->version;
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Collection;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
/**
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Content\Translation>
*/
class Translations extends Collection
{
/**
* Creates a new Translations collection from
* an array of translations properties. This is
* used in ModelWithContent::setTranslations to properly
* normalize an array definition.
*
* @todo Needs to be refactored as soon as Version::create becomes static
* (see https://github.com/getkirby/kirby/pull/6491#discussion_r1652264408)
*/
public static function create(
ModelWithContent $model,
Version $version,
array $translations
): static {
foreach ($translations as $translation) {
Translation::create(
model: $model,
version: $version,
language: Language::ensure($translation['code'] ?? 'default'),
fields: $translation['content'] ?? [],
slug: $translation['slug'] ?? null
);
}
return static::load(
model: $model,
version: $version
);
}
/**
* Simplifies `Translations::find` by allowing to pass
* Language codes that will be properly validated here.
*/
public function findByKey(string $key): Translation|null
{
return parent::get(Language::ensure($key)->code());
}
/**
* Loads all available translations for a given model
*/
public static function load(
ModelWithContent $model,
Version $version
): static {
$translations = [];
foreach (Languages::ensure() as $language) {
$translations[] = new Translation(
model: $model,
version: $version,
language: $language
);
}
return new static($translations);
}
}

View file

@ -0,0 +1,687 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Fields;
use Kirby\Http\Uri;
/**
* The Version class handles all actions for a single
* version and is identified by a VersionId instance
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class Version
{
public function __construct(
protected ModelWithContent $model,
protected VersionId $id
) {
}
/**
* Returns a Content object for the given language
*/
public function content(Language|string $language = 'default'): Content
{
$language = Language::ensure($language);
$fields = $this->read($language) ?? [];
// This is where we merge content from the default language
// to provide a fallback for missing/untranslated fields.
//
// @todo This is the critical point that needs to be removed/refactored
// in the future, to provide multi-language support with truly
// individual versions of pages and no longer enforce the fallback.
if ($language->isDefault() === false) {
// merge the fields with the default language
$fields = [
...$this->read('default') ?? [],
...$fields
];
}
// remove fields that should not be used for the Content object
unset($fields['lock']);
return new Content(
parent: $this->model,
data: $fields,
normalize: false
);
}
/**
* Provides simplified access to the absolute content file path.
* This should stay an internal method and be removed as soon as
* the dependency on file storage methods is resolved more clearly.
*
* @internal
*/
public function contentFile(Language|string $language = 'default'): string
{
return $this->model->storage()->contentFile(
$this->id,
Language::ensure($language)
);
}
/**
* Make sure that all field names are converted to lower
* case to be able to merge and filter them properly
*/
protected function convertFieldNamesToLowerCase(array $fields): array
{
return array_change_key_case($fields, CASE_LOWER);
}
/**
* Creates a new version for the given language
* @todo Convert to a static method that creates the version initially with all relevant languages
*
* @param array<string, string> $fields Content fields
*/
public function create(
array $fields,
Language|string $language = 'default'
): void {
$language = Language::ensure($language);
// check if creating is allowed
VersionRules::create($this, $fields, $language);
// track the changes
if ($this->id->is('changes') === true) {
(new Changes())->track($this->model);
}
$this->model->storage()->create(
versionId: $this->id,
language: $language,
fields: $this->prepareFieldsBeforeWrite($fields, $language)
);
// make sure that an older version does not exist in the cache
VersionCache::remove($this, $language);
}
/**
* Deletes a version for a specific language
*/
public function delete(Language|string $language = 'default'): void
{
if ($language === '*') {
foreach (Languages::ensure() as $language) {
$this->delete($language);
}
return;
}
$language = Language::ensure($language);
// check if deleting is allowed
VersionRules::delete($this, $language);
$this->model->storage()->delete($this->id, $language);
// untrack the changes if the version does no longer exist
// in any of the available languages
if (
$this->id->is('changes') === true &&
$this->exists('*') === false
) {
(new Changes())->untrack($this->model);
}
// Remove the version from the cache
VersionCache::remove($this, $language);
}
/**
* Returns all validation errors for the given language
*/
public function errors(Language|string $language = 'default'): array
{
$fields = Fields::for($this->model, $language);
$fields->fill(
input: $this->content($language)->toArray()
);
return $fields->errors();
}
/**
* Checks if a version exists for the given language
*/
public function exists(Language|string $language = 'default'): bool
{
// go through all possible languages to check if this
// version exists in any language
if ($language === '*') {
foreach (Languages::ensure() as $language) {
if ($this->exists($language) === true) {
return true;
}
}
return false;
}
return $this->model->storage()->exists(
$this->id,
Language::ensure($language)
);
}
/**
* Returns the VersionId instance for this version
*/
public function id(): VersionId
{
return $this->id;
}
/**
* Returns whether the content of both versions
* is identical
*/
public function isIdentical(
Version|VersionId|string $version,
Language|string $language = 'default'
): bool {
if (is_string($version) === true) {
$version = VersionId::from($version);
}
if ($version instanceof VersionId) {
$version = $this->sibling($version);
}
if ($version->id()->is($this->id) === true) {
return true;
}
$language = Language::ensure($language);
$fields = Fields::for($this->model, $language);
// read fields low-level from storage
$a = $this->read($language) ?? [];
$b = $version->read($language) ?? [];
// remove fields that should not be
// considered in the comparison
unset(
$a['lock'],
$b['lock'],
$a['uuid'],
$b['uuid']
);
$a = $fields->reset()->fill(input: $a)->toFormValues();
$b = $fields->reset()->fill(input: $b)->toFormValues();
ksort($a);
ksort($b);
return $a === $b;
}
/**
* Checks if the version is the latest version
*/
public function isLatest(): bool
{
return $this->id->is('latest');
}
/**
* Checks if the version is locked for the current user
*/
public function isLocked(Language|string $language = 'default'): bool
{
return $this->lock($language)->isLocked();
}
/**
* Checks if there are any validation errors for the given language
*/
public function isValid(Language|string $language = 'default'): bool
{
return $this->errors($language) === [];
}
/**
* Returns the lock object for the version
*/
public function lock(Language|string $language = 'default'): Lock
{
return Lock::for($this, $language);
}
/**
* Returns the parent model
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* Returns the modification timestamp of a version
* if it exists
*/
public function modified(
Language|string $language = 'default'
): int|null {
if ($this->exists($language) === true) {
return $this->model->storage()->modified(
$this->id,
Language::ensure($language)
);
}
return null;
}
/**
* Moves the version to a new language and/or version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function move(
Language|string $fromLanguage,
VersionId|null $toVersionId = null,
Language|string|null $toLanguage = null,
Storage|null $toStorage = null
): void {
$fromVersion = $this;
$fromLanguage = Language::ensure($fromLanguage);
$toLanguage = Language::ensure($toLanguage ?? $fromLanguage);
$toVersion = $this->sibling($toVersionId ?? $this->id);
// check if moving is allowed
VersionRules::move(
fromVersion: $fromVersion,
fromLanguage: $fromLanguage,
toVersion: $toVersion,
toLanguage: $toLanguage
);
$this->model->storage()->move(
fromVersionId: $fromVersion->id(),
fromLanguage: $fromLanguage,
toVersionId: $toVersion->id(),
toLanguage: $toLanguage,
toStorage: $toStorage
);
// remove both versions from the cache
VersionCache::remove($fromVersion, $fromLanguage);
VersionCache::remove($toVersion, $toLanguage);
}
/**
* Prepare fields to be written by removing unwanted fields
* depending on the language or model and by cleaning the field names
*/
protected function prepareFieldsBeforeWrite(
array $fields,
Language $language
): array {
// convert all field names to lower case
$fields = $this->convertFieldNamesToLowerCase($fields);
// make sure to store the right fields for the model
$fields = $this->model->contentFileData($fields, $language);
// add the editing user
if (
Lock::isEnabled() === true &&
$this->id->is('changes') === true
) {
$fields['lock'] = $this->model->kirby()->user()?->id();
// remove the lock field for any other version or
// if locking is disabled
} else {
unset($fields['lock']);
}
// the default language stores all fields
if ($language->isDefault() === true) {
return $fields;
}
// remove all untranslatable fields
foreach ($this->model->blueprint()->fields() as $field) {
if (($field['translate'] ?? true) === false) {
unset($fields[strtolower($field['name'])]);
}
}
// remove UUID for non-default languages
unset($fields['uuid']);
return $fields;
}
/**
* Make sure that reading from storage will always
* return a usable set of fields with clean field names
*/
protected function prepareFieldsAfterRead(array $fields, Language $language): array
{
$fields = $this->convertFieldNamesToLowerCase($fields);
// ignore all fields with null values
return array_filter($fields, fn ($field) => $field !== null);
}
/**
* Returns a verification token for the authentication
* of draft and version previews
* @unstable
*/
public function previewToken(): string
{
if ($this->model instanceof Site) {
// the site itself does not render; its preview is the home page
$homePage = $this->model->homePage();
if ($homePage === null) {
throw new NotFoundException('The home page does not exist');
}
return $homePage->version($this->id)->previewToken();
}
if (($this->model instanceof Page) === false) {
throw new LogicException('Invalid model type');
}
return $this->previewTokenFromUrl($this->model->url());
}
/**
* Returns a verification token for the authentication
* of draft and version previews from a raw URL
*/
protected function previewTokenFromUrl(string $url): string
{
// get rid of all modifiers after the path
$uri = new Uri($url);
$uri->fragment = null;
$uri->params = null;
$uri->query = null;
$data = [
'url' => $uri->toString(),
'versionId' => $this->id->value()
];
$token = $this->model->kirby()->contentToken(
null,
json_encode($data, JSON_UNESCAPED_SLASHES)
);
return substr($token, 0, 10);
}
/**
* This method can only be applied to the "changes" version.
* It will copy all fields over to the "latest" version and delete
* this version afterwards.
*/
public function publish(Language|string $language = 'default'): void
{
$language = Language::ensure($language);
// check if publishing is allowed
VersionRules::publish($this, $language);
$latest = $this->sibling('latest')->read($language) ?? [];
$changes = $this->read($language) ?? [];
// overwrite all fields that are not in the `changes` version
// with a null value. The ModelWithContent::update method will merge
// the input with the existing content fields and setting null values
// for removed fields will take care of not inheriting old values.
foreach ($latest as $key => $value) {
if (isset($changes[$key]) === false) {
$changes[$key] = null;
}
}
// update the latest version
$this->model = $this->model->update(
input: $changes,
languageCode: $language->code(),
validate: true
);
// delete the changes
$this->delete($language);
}
/**
* Returns the stored content fields
*
* @return array<string, string>|null
*/
public function read(Language|string $language = 'default'): array|null
{
$language = Language::ensure($language);
try {
// make sure that the version exists
VersionRules::read($this, $language);
$fields = VersionCache::get($this, $language);
if ($fields === null) {
$fields = $this->model->storage()->read($this->id, $language);
$fields = $this->prepareFieldsAfterRead($fields, $language);
if ($fields !== null) {
VersionCache::set($this, $language, $fields);
}
}
return $fields;
} catch (NotFoundException) {
return null;
}
}
/**
* Replaces the content of the current version with the given fields
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function replace(
array $fields,
Language|string $language = 'default'
): void {
$language = Language::ensure($language);
// check if replacing is allowed
VersionRules::replace($this, $fields, $language);
$this->model->storage()->update(
versionId: $this->id,
language: $language,
fields: $this->prepareFieldsBeforeWrite($fields, $language)
);
// remove the version from the cache to read
// a fresh version next time
VersionCache::remove($this, $language);
}
/**
* Convenience wrapper around ::create, ::replace and ::update.
*/
public function save(
array $fields,
Language|string $language = 'default',
bool $overwrite = false
): void {
if ($this->exists($language) === false) {
$this->create($fields, $language);
return;
}
if ($overwrite === true) {
$this->replace($fields, $language);
return;
}
$this->update($fields, $language);
}
/**
* Returns a sibling version for the same model
*/
public function sibling(VersionId|string $id): Version
{
return new Version(
model: $this->model,
id: VersionId::from($id)
);
}
/**
* Updates the modification timestamp of an existing version
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function touch(Language|string $language = 'default'): void
{
$language = Language::ensure($language);
VersionRules::touch($this, $language);
$this->model->storage()->touch($this->id, $language);
}
/**
* Updates the content fields of an existing version
*
* @param array<string, string> $fields Content fields
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public function update(
array $fields,
Language|string $language = 'default'
): void {
$language = Language::ensure($language);
// check if updating is allowed
VersionRules::update($this, $fields, $language);
// merge the previous state with the new state to always
// update to a complete version
$fields = [
...$this->read($language),
...$fields
];
$this->model->storage()->update(
versionId: $this->id,
language: $language,
fields: $this->prepareFieldsBeforeWrite($fields, $language)
);
// remove the version from the cache to read
// a fresh version next time
VersionCache::remove($this, $language);
}
/**
* Returns the preview URL with authentication for drafts and versions
* @unstable
*/
public function url(): string|null
{
if (
($this->model instanceof Page || $this->model instanceof Site) === false
) {
throw new LogicException('Only pages and the site have a content preview URL');
}
$url = $this->model->blueprint()->preview();
// preview was disabled
if ($url === false) {
return null;
}
// we only need to add a token for draft and changes previews
if (
($this->model instanceof Site || $this->model->isDraft() === false) &&
$this->id->is('changes') === false
) {
return match (true) {
is_string($url) => $url,
default => $this->model->url()
};
}
// check if the URL was customized
if (is_string($url) === true) {
return $this->urlFromOption($url);
}
// it wasn't, use the safer/more reliable model-based preview token
return $this->urlWithQueryParams($this->model->url(), $this->previewToken());
}
/**
* Returns the preview URL based on an arbitrary URL from
* the blueprint option
*/
protected function urlFromOption(string $url): string
{
// try to determine a token for a local preview
// (we cannot determine the token for external previews)
if ($token = $this->previewTokenFromUrl($url)) {
return $this->urlWithQueryParams($url, $token);
}
// fall back to the URL as defined in the blueprint
return $url;
}
/**
* Assembles the preview URL with the added `_token` and `_version`
* query params, no matter if the base URL already contains query params
*/
protected function urlWithQueryParams(string $baseUrl, string $token): string
{
$uri = new Uri($baseUrl);
$uri->query->_token = $token;
if ($this->id->is('changes') === true) {
$uri->query->_version = 'changes';
}
return $uri->toString();
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use WeakMap;
/**
* The Version cache class keeps content fields
* to avoid multiple storage reads for the same
* content.
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionCache
{
/**
* All cache values for all versions
* and language combinations
*/
protected static WeakMap $cache;
/**
* Tries to receive a fields for a version/language combination
*/
public static function get(Version $version, Language $language): array|null
{
$model = $version->model();
$key = $version->id() . ':' . $language->code();
return static::$cache[$model][$key] ?? null;
}
/**
* Removes fields for a version/language combination
*/
public static function remove(Version $version, Language $language): void
{
$model = $version->model();
if (isset(static::$cache[$model]) === false) {
return;
}
// Avoid indirect manipulation of WeakMap
$key = $version->id() . ':' . $language->code();
$map = static::$cache[$model];
unset($map[$key]);
static::$cache[$model] = $map;
}
/**
* Resets the cache
*/
public static function reset(): void
{
static::$cache = new WeakMap();
}
/**
* Keeps fields for a version/language combination
*/
public static function set(
Version $version,
Language $language,
array $fields = []
): void {
$model = $version->model();
$key = $version->id() . ':' . $language->code();
static::$cache ??= new WeakMap();
static::$cache[$model] ??= [];
static::$cache[$model][$key] = $fields;
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Kirby\Content;
use Closure;
use Kirby\Exception\InvalidArgumentException;
use Stringable;
/**
* The Version ID identifies a version of content.
* This can be the currently latest version or changes
* to the content. In the future, we also plan to use this
* for older revisions of the content.
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionId implements Stringable
{
/**
* Latest stable version of the content
*/
public const LATEST = 'latest';
/**
* Latest changes to the content (optional)
*/
public const CHANGES = 'changes';
/**
* A global store for a version id that should be
* rendered for each model in a live preview scenario.
*/
public static self|null $render = null;
/**
* @throws \Kirby\Exception\InvalidArgumentException If the version ID is not valid
*/
public function __construct(
public string $value
) {
if (in_array($value, [static::CHANGES, static::LATEST], true) === false) {
throw new InvalidArgumentException(message: 'Invalid Version ID');
}
}
/**
* Converts the VersionId instance to a simple string value
*/
public function __toString(): string
{
return $this->value;
}
/**
* Creates a VersionId instance for the latest content changes
*/
public static function changes(): static
{
return new static(static::CHANGES);
}
/**
* Creates a VersionId instance from a simple string value
*/
public static function from(VersionId|string $value): static
{
if ($value instanceof VersionId) {
return $value;
}
return new static($value);
}
/**
* Compares a VersionId object or string value with this id
*/
public function is(VersionId|string $id): bool
{
return static::from($id)->value === $this->value;
}
/**
* Creates a VersionId instance for the latest stable version of the content
*/
public static function latest(): static
{
return new static(static::LATEST);
}
/**
* Temporarily sets the version ID for preview rendering
* only for the logic in the callback
*/
public static function render(VersionId|string $versionId, Closure $callback): mixed
{
$original = static::$render;
static::$render = static::from($versionId);
try {
return $callback();
} finally {
// ensure that the render version ID is *always* reset
// to the original value, even if an error occurred
static::$render = $original;
}
}
/**
* Returns the ID value
*/
public function value(): string
{
return $this->value;
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Language;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
/**
* The VersionRules class handles the validation for all
* modification actions on a single version
*
* @package Kirby Content
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionRules
{
public static function create(
Version $version,
array $fields,
Language $language
): void {
if ($version->exists($language) === true) {
throw new LogicException(
message: 'The version already exists'
);
}
}
/**
* Checks if a version/language combination exists and otherwise
* will throw a `NotFoundException`
*
* @throws \Kirby\Exception\NotFoundException If the version does not exist
*/
public static function ensure(Version $version, Language $language): void
{
if ($version->exists($language) === true) {
return;
}
$message = match($version->model()->kirby()->multilang()) {
true => 'Version "' . $version->id() . ' (' . $language->code() . ')" does not already exist',
false => 'Version "' . $version->id() . '" does not already exist',
};
throw new NotFoundException($message);
}
public static function delete(
Version $version,
Language $language
): void {
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.delete'
);
}
}
public static function move(
Version $fromVersion,
Language $fromLanguage,
Version $toVersion,
Language $toLanguage
): void {
// make sure that the source version exists
static::ensure($fromVersion, $fromLanguage);
// check if the source version is locked in any language
if ($fromVersion->isLocked('*') === true) {
throw new LockedContentException(
lock: $fromVersion->lock('*'),
key: 'content.lock.move'
);
}
// check if the target version is locked in any language
if ($toVersion->isLocked('*') === true) {
throw new LockedContentException(
lock: $toVersion->lock('*'),
key: 'content.lock.update'
);
}
}
public static function publish(
Version $version,
Language $language
): void {
// the latest version is already published
if ($version->isLatest() === true) {
throw new LogicException(
message: 'This version is already published'
);
}
// make sure that the version exists
static::ensure($version, $language);
// check if the version is locked in any language
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.publish'
);
}
}
public static function read(
Version $version,
Language $language
): void {
static::ensure($version, $language);
}
public static function replace(
Version $version,
array $fields,
Language $language
): void {
// make sure that the version exists
static::ensure($version, $language);
// check if the version is locked in any language
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.replace'
);
}
}
public static function touch(
Version $version,
Language $language
): void {
static::ensure($version, $language);
}
public static function update(
Version $version,
array $fields,
Language $language
): void {
static::ensure($version, $language);
if ($version->isLocked('*') === true) {
throw new LockedContentException(
lock: $version->lock('*'),
key: 'content.lock.update'
);
}
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Kirby\Content;
use Kirby\Cms\Collection;
use Kirby\Cms\ModelWithContent;
/**
* @package Kirby Content
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Content\Version>
*/
class Versions extends Collection
{
/**
* Deletes all versions in the collection
*/
public function delete(): void
{
foreach ($this->data as $version) {
$version->delete('*');
}
}
/**
* Loads all available versions for a given model
*
* Versions need to be loaded in the order `changes`, `latest`
* to ensure that models are deleted correctly. The `latest`
* version always needs to be deleted last, otherwise the
* PlainTextStorage handler will not be able to clean up
* content directories.
*/
public static function load(
ModelWithContent $model
): static {
return new static(
objects: [
$model->version('changes'),
$model->version('latest'),
],
parent: $model
);
}
}

View file

@ -0,0 +1,211 @@
<?php
namespace Kirby\Form\Field;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Form\FieldClass;
use Kirby\Form\Form;
use Kirby\Form\Mixin\EmptyState;
use Kirby\Form\Mixin\Max;
use Kirby\Form\Mixin\Min;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Main class file of the entries field
*
* @package Kirby Field
* @author Ahmet Bora <ahmet@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class EntriesField extends FieldClass
{
use EmptyState;
use Max;
use Min;
protected array $field;
protected Form $form;
protected bool $sortable = true;
public function __construct(array $params = [])
{
parent::__construct($params);
$this->setEmpty($params['empty'] ?? null);
$this->setField($params['field'] ?? null);
$this->setMax($params['max'] ?? null);
$this->setMin($params['min'] ?? null);
$this->setSortable($params['sortable'] ?? true);
}
public function field(): array
{
return $this->field;
}
public function fieldProps(): array
{
return $this->form()->fields()->first()->toArray();
}
/**
* @psalm-suppress MethodSignatureMismatch
* @todo Remove psalm suppress after https://github.com/vimeo/psalm/issues/8673 is fixed
*/
public function fill(mixed $value): static
{
$this->value = Data::decode($value ?? '', 'yaml');
return $this;
}
public function form(): Form
{
return $this->form ??= new Form(
fields: [$this->field()],
model: $this->model
);
}
public function props(): array
{
return [
...parent::props(),
'empty' => $this->empty(),
'field' => $this->fieldProps(),
'max' => $this->max(),
'min' => $this->min(),
'sortable' => $this->sortable(),
];
}
protected function setField(array|string|null $attrs = null): void
{
if (is_string($attrs) === true) {
$attrs = ['type' => $attrs];
}
$attrs ??= ['type' => 'text'];
if (in_array($attrs['type'], $this->supports()) === false) {
throw new InvalidArgumentException(
key: 'entries.supports',
data: ['type' => $attrs['type']]
);
}
// remove the unsupported props from the entry field
unset($attrs['counter'], $attrs['label']);
$this->field = $attrs;
}
protected function setSortable(bool|null $sortable = true): void
{
$this->sortable = $sortable;
}
public function sortable(): bool
{
return $this->sortable;
}
public function supports(): array
{
return [
'color',
'date',
'email',
'number',
'select',
'slug',
'tel',
'text',
'time',
'url'
];
}
public function toFormValue(): mixed
{
$form = $this->form();
$value = parent::toFormValue() ?? [];
return A::map(
$value,
fn ($value) => $form
->reset()
->fill(input: [$value])
->fields()
->first()
->toFormValue()
);
}
public function toStoredValue(): mixed
{
$form = $this->form();
$value = parent::toStoredValue();
return A::map(
$value,
fn ($value) => $form
->reset()
->submit(input: [$value])
->fields()
->first()
->toStoredValue()
);
}
public function validations(): array
{
return [
'entries' => function ($value) {
if ($this->min && count($value) < $this->min) {
throw new InvalidArgumentException(
key: match ($this->min) {
1 => 'entries.min.singular',
default => 'entries.min.plural'
},
data: ['min' => $this->min]
);
}
if ($this->max && count($value) > $this->max) {
throw new InvalidArgumentException(
key: match ($this->max) {
1 => 'entries.max.singular',
default => 'entries.max.plural'
},
data: ['max' => $this->max]
);
}
$form = $this->form();
foreach ($value as $index => $val) {
$form->reset()->submit(input: [$val]);
foreach ($form->fields() as $field) {
$errors = $field->errors();
if ($errors !== []) {
throw new InvalidArgumentException(
key: 'entries.validation',
data: [
'field' => $this->label() ?? Str::ucfirst($this->name()),
'index' => $index + 1
]
);
}
}
}
}
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace Kirby\Form\Mixin;
trait Api
{
public function api(): array
{
return $this->routes();
}
/**
* Routes for the field API
*/
public function routes(): array
{
return [];
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Kirby\Form\Mixin;
use Kirby\Cms\App;
use Kirby\Cms\ModelWithContent;
trait Model
{
protected ModelWithContent $model;
/**
* Returns the Kirby instance
*/
public function kirby(): App
{
return $this->model->kirby();
}
/**
* Returns the parent model
*/
public function model(): ModelWithContent
{
return $this->model;
}
/**
* Sets the parent model
*/
protected function setModel(ModelWithContent|null $model = null): void
{
$this->model = $model ?? App::instance()->site();
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Kirby\Form\Mixin;
use Kirby\Cms\Language;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait Translatable
{
protected bool $translate = true;
/**
* Should the field be translatable into the given language?
*
* @since 5.0.0
*/
public function isTranslatable(Language $language): bool
{
if ($this->translate() === false && $language->isDefault() === false) {
return false;
}
return true;
}
/**
* Set the translatable status
*/
protected function setTranslate(bool $translate = true): void
{
$this->translate = $translate;
}
/**
* Should the field be translatable?
*/
public function translate(): bool
{
return $this->translate;
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace Kirby\Form\Mixin;
use Closure;
use Exception;
use Kirby\Form\Validations;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\V;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait Validation
{
protected bool $required;
/**
* Runs all validations and returns an array of
* error messages
*/
public function errors(): array
{
$validations = $this->validations();
$value = $this->value();
$errors = [];
// validate required values
if ($this->needsValue() === true) {
$errors['required'] = I18n::translate('error.validation.required');
}
foreach ($validations as $key => $validation) {
if (is_int($key) === true) {
// predefined validation
try {
Validations::$validation($this, $value);
} catch (Exception $e) {
$errors[$validation] = $e->getMessage();
}
continue;
}
if ($validation instanceof Closure) {
try {
$validation->call($this, $value);
} catch (Exception $e) {
$errors[$key] = $e->getMessage();
}
}
}
if (
empty($this->validate) === false &&
($this->isEmpty() === false || $this->isRequired() === true)
) {
$rules = A::wrap($this->validate);
$errors = [
...$errors,
...V::errors($value, $rules)
];
}
return $errors;
}
/**
* Checks if the field is required
*/
public function isRequired(): bool
{
return $this->required;
}
/**
* Checks if the field is invalid
*/
public function isInvalid(): bool
{
return $this->errors() !== [];
}
/**
* Checks if the field is valid
*/
public function isValid(): bool
{
return $this->errors() === [];
}
/**
* Getter for the required property
*/
public function required(): bool
{
return $this->required;
}
protected function setRequired(bool $required = false): void
{
$this->required = $required;
}
/**
* Defines all validation rules
*/
protected function validations(): array
{
return [];
}
}

View file

@ -0,0 +1,220 @@
<?php
namespace Kirby\Form\Mixin;
use Kirby\Cms\Language;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait Value
{
protected mixed $default = null;
protected mixed $value = null;
/**
* @deprecated 5.0.0 Use `::toStoredValue()` instead to receive
* the value in the format that will be needed for content files.
*
* If you need to get the value with the default as fallback, you should use
* the fill method first `$field->fill($field->default())->toStoredValue()`
*/
public function data(bool $default = false): mixed
{
if ($default === true && $this->isEmpty() === true) {
$this->fill($this->default());
}
return $this->toStoredValue();
}
/**
* Returns the default value of the field
*/
public function default(): mixed
{
if (is_string($this->default) === false) {
return $this->default;
}
return $this->model->toString($this->default);
}
/**
* Sets a new value for the field
*/
public function fill(mixed $value): static
{
$this->value = $value;
return $this;
}
/**
* Checks if the field has a value
*/
public function hasValue(): bool
{
return true;
}
/**
* Checks if the field is empty
*/
public function isEmpty(): bool
{
return $this->isEmptyValue($this->toFormValue());
}
/**
* Checks if the given value is considered empty
*/
public function isEmptyValue(mixed $value = null): bool
{
return in_array($value, [null, '', []], true);
}
/**
* Checks if the field can be stored in the given language.
*/
public function isStorable(Language $language): bool
{
// the field cannot be stored at all if it has no value
if ($this->hasValue() === false) {
return false;
}
// the field cannot be translated into the given language
if ($this->isTranslatable($language) === false) {
return false;
}
// We don't need to check if the field is disabled.
// A disabled field can still have a value and that value
// should still be stored. But that value must not be changed
// on submit. That's why we check for the disabled state
// in the isSubmittable method.
return true;
}
/**
* A field might have a value, but can still not be submitted
* because it is disabled, not translatable into the given
* language or not active due to a `when` rule.
*/
public function isSubmittable(Language $language): bool
{
if ($this->hasValue() === false) {
return false;
}
if ($this->isDisabled() === true) {
return false;
}
if ($this->isTranslatable($language) === false) {
return false;
}
if ($this->isActive() === false) {
return false;
}
return true;
}
/**
* Checks if the field needs a value before being saved;
* this is the case if all of the following requirements are met:
* - The field has a value
* - The field is required
* - The field is currently empty
* - The field is not currently inactive because of a `when` rule
*/
protected function needsValue(): bool
{
if (
$this->hasValue() === false ||
$this->isRequired() === false ||
$this->isEmpty() === false ||
$this->isActive() === false
) {
return false;
}
return true;
}
/**
* Checks if the field is saveable
* @deprecated 5.0.0 Use `::hasValue()` instead
*/
public function save(): bool
{
return $this->hasValue();
}
protected function setDefault(mixed $default = null): void
{
$this->default = $default;
}
/**
* Submits a new value for the field.
* Fields can overwrite this method to provide custom
* submit logic. This is useful if the field component
* sends data that needs to be processed before being
* stored.
*
* @since 5.0.0
*/
public function submit(mixed $value): static
{
return $this->fill($value);
}
/**
* Returns the value of the field in a format to be used in forms
* (e.g. used as data for Panel Vue components)
*/
public function toFormValue(): mixed
{
if ($this->hasValue() === false) {
return null;
}
return $this->value;
}
/**
* Returns the value of the field in a format
* to be stored by our storage classes
*/
public function toStoredValue(): mixed
{
return $this->toFormValue();
}
/**
* Returns the value of the field if it has a value
* otherwise it returns null
*
* @see `self::toFormValue()`
* @todo might get deprecated or reused later. Use `self::toFormValue()` instead.
*
* If you need the form value with the default as fallback, you should use
* the fill method first `$field->fill($field->default())->toFormValue()`
*/
public function value(bool $default = false): mixed
{
if ($default === true && $this->isEmpty() === true) {
$this->fill($this->default());
}
return $this->toFormValue();
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace Kirby\Form\Mixin;
/**
* @package Kirby Form
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
trait When
{
protected array|null $when = null;
/**
* Checks if the field is currently active
* or hidden because of a `when` condition
*/
public function isActive(): bool
{
if ($this->when === null || $this->when === []) {
return true;
}
$siblings = $this->siblings();
foreach ($this->when as $field => $value) {
$field = $siblings->get($field);
$input = $field?->value() ?? '';
// if the input data doesn't match the requested `when` value,
// that means that this field is not required and can be saved
// (*all* `when` conditions must be met for this field to be required)
if ($input !== $value) {
return false;
}
}
return true;
}
/**
* Setter for the `when` condition
*/
protected function setWhen(array|null $when = null): void
{
$this->when = $when;
}
/**
* Returns the `when` condition of the field
*/
public function when(): array|null
{
return $this->when;
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Kirby\Panel\Controller;
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Toolkit\I18n;
/**
* The PageTree controller takes care of the request logic
* for the `k-page-tree` component and similar
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class PageTree
{
protected Site $site;
public function __construct(
) {
$this->site = App::instance()->site();
}
/**
* Returns children for the parent as entries
*/
public function children(
string|null $parent = null,
string|null $moving = null
): array {
if ($moving !== null) {
$moving = Find::parent($moving);
}
if ($parent === null) {
return [
$this->entry($this->site, $moving)
];
}
return Find::parent($parent)
->childrenAndDrafts()
->filterBy('isListable', true)
->values(
fn ($child) => $this->entry($child, $moving)
);
}
/**
* Returns the properties to display the site or page
* as an entry in the page tree component
*/
public function entry(
Site|Page $entry,
Page|null $moving = null
): array {
$panel = $entry->panel();
$id = $entry->id() ?? '/';
$uuid = $entry->uuid()?->toString();
$url = $entry->url();
$value = $uuid ?? $id;
return [
'children' => $panel->url(true),
'disabled' => $moving?->isMovableTo($entry) === false,
'hasChildren' =>
$entry->hasChildren() === true ||
$entry->hasDrafts() === true,
'icon' => match (true) {
$entry instanceof Site => 'home',
default => $panel->image()['icon'] ?? null
},
'id' => $id,
'open' => false,
'label' => match (true) {
$entry instanceof Site => I18n::translate('view.site'),
default => $entry->title()->value()
},
'url' => $url,
'uuid' => $uuid,
'value' => $value
];
}
/**
* Returns the UUIDs/ids for all parents of the page
*/
public function parents(
string|null $page = null,
bool $includeSite = false,
): array {
$page = $this->site->page($page);
$parents = $page?->parents()->flip();
$parents = $parents?->values(
fn ($parent) => $parent->uuid()?->toString() ?? $parent->id()
);
$parents ??= [];
if ($includeSite === true) {
array_unshift($parents, $this->site->uuid()?->toString() ?? '/');
}
return [
'data' => $parents
];
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Kirby\Panel\Controller;
use Kirby\Cms\App;
use Kirby\Toolkit\Escape;
/**
* The Search controller takes care of the logic
* for delivering Panel search results
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @unstable
*/
class Search
{
public static function files(
string|null $query = null,
int|null $limit = null,
int $page = 1
): array {
$kirby = App::instance();
$files = $kirby->site()
->index(true)
->filter('isListable', true)
->files();
// add site files which aren't considered by the index
$files = $files->add($kirby->site()->files());
// filter and search among those files
$files = $files->filter('isListable', true)->search($query);
if ($limit !== null) {
$files = $files->paginate($limit, $page);
}
return [
'results' => $files->values(fn ($file) => [
'image' => $file->panel()->image(),
'text' => Escape::html($file->filename()),
'link' => $file->panel()->url(true),
'info' => Escape::html($file->id()),
'uuid' => $file->uuid()->toString(),
]),
'pagination' => $files->pagination()?->toArray()
];
}
public static function pages(
string|null $query = null,
int|null $limit = null,
int $page = 1
): array {
$kirby = App::instance();
$pages = $kirby->site()
->index(true)
->search($query)
->filter('isListable', true);
if ($limit !== null) {
$pages = $pages->paginate($limit, $page);
}
return [
'results' => $pages->values(fn ($page) => [
'image' => $page->panel()->image(),
'text' => Escape::html($page->title()->value()),
'link' => $page->panel()->url(true),
'info' => Escape::html($page->id()),
'uuid' => $page->uuid()?->toString(),
]),
'pagination' => $pages->pagination()?->toArray()
];
}
public static function users(
string|null $query = null,
int|null $limit = null,
int $page = 1
): array {
$kirby = App::instance();
$users = $kirby->users()->search($query);
if ($limit !== null) {
$users = $users->paginate($limit, $page);
}
return [
'results' => $users->values(fn ($user) => [
'image' => $user->panel()->image(),
'text' => Escape::html($user->username()),
'link' => $user->panel()->url(true),
'info' => Escape::html($user->role()->title()),
'uuid' => $user->uuid()->toString(),
]),
'pagination' => $users->pagination()?->toArray()
];
}
}

194
kirby/src/Panel/Lab/Doc.php Normal file
View file

@ -0,0 +1,194 @@
<?php
namespace Kirby\Panel\Lab;
use Kirby\Cms\App;
use Kirby\Data\Data;
use Kirby\Panel\Lab\Doc\Event;
use Kirby\Panel\Lab\Doc\Method;
use Kirby\Panel\Lab\Doc\Prop;
use Kirby\Panel\Lab\Doc\Slot;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Documentation for a single Vue component
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Doc
{
protected array $data;
public function __construct(
public string $name,
public string $source,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $docBlock = null,
public array $events = [],
public array $examples = [],
public bool $isUnstable = false,
public array $methods = [],
public array $props = [],
public string|null $since = null,
public array $slots = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
$this->docBlock = Doc::kt($this->docBlock ?? '');
}
/**
* Checks if a documentation file exists for the component
*/
public static function exists(string $name): bool
{
return
file_exists(static::file($name, 'dist')) ||
file_exists(static::file($name, 'dev'));
}
public static function factory(string $name): static|null
{
// protect against path traversal
$name = basename($name);
// read data
$file = static::file($name, 'dev');
if (file_exists($file) === false) {
$file = static::file($name, 'dist');
}
$data = Data::read($file);
// filter internal components
if (isset($data['tags']['internal']) === true) {
return null;
}
// helper function for gathering parts
$gather = function (string $part, string $class) use ($data) {
$parts = A::map(
$data[$part] ?? [],
fn ($x) => $class::factory($x)?->toArray()
);
$parts = array_filter($parts);
usort($parts, fn ($a, $b) => $a['name'] <=> $b['name']);
return $parts;
};
return new static(
name: $name,
source: $data['sourceFile'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
docBlock: $data['docsBlocks'][0] ?? null,
examples: $data['tags']['examples'] ?? [],
events: $gather('events', Event::class),
isUnstable: isset($data['tags']['unstable']) === true,
methods: $gather('methods', Method::class),
props: $gather('props', Prop::class),
since: $data['tags']['since'][0]['description'] ?? null,
slots: $gather('slots', Slot::class)
);
}
/**
* Returns the path to the documentation file for the component
*/
public static function file(string $name, string $context): string
{
$root = match ($context) {
'dev' => App::instance()->root('panel') . '/tmp',
'dist' => App::instance()->root('panel') . '/dist/ui',
};
$name = Str::after($name, 'k-');
$name = Str::kebabToCamel($name);
return $root . '/' . $name . '.json';
}
/**
* Helper to resolve KirbyText
*/
public static function kt(string $text, bool $inline = false): string
{
return App::instance()->kirbytext($text, [
'markdown' => [
'breaks' => false,
'inline' => $inline,
]
]);
}
/**
* Returns the path to the Lab examples, if available
*/
public function lab(): string|null
{
$root = App::instance()->root('panel') . '/lab';
foreach (glob($root . '/{,*/,*/*/,*/*/*/}index.php', GLOB_BRACE) as $example) {
$props = require $example;
if (($props['docs'] ?? null) === $this->name) {
return Str::before(Str::after($example, $root), 'index.php');
}
}
return null;
}
public function source(): string
{
return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->source;
}
/**
* Returns the data for this documentation
*/
public function toArray(): array
{
return [
'component' => $this->name,
'deprecated' => $this->deprecated,
'description' => $this->description,
'docBlock' => $this->docBlock,
'events' => $this->events,
'examples' => $this->examples,
'isUnstable' => $this->isUnstable,
'methods' => $this->methods,
'props' => $this->props,
'since' => $this->since,
'slots' => $this->slots,
'source' => $this->source(),
];
}
/**
* Returns the information to display as
* entry in a collection (e.g. on the Lab index view)
*/
public function toItem(): array
{
return [
'image' => [
'icon' => $this->isUnstable ? 'lab' : 'book',
'back' => 'light-dark(white, var(--color-gray-800))',
],
'text' => $this->name,
'link' => '/lab/docs/' . $this->name,
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
/**
* Documentation for a single argument for an event, slot or method
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Argument
{
public function __construct(
public string $name,
public string|null $type = null,
public string|null $description = null,
) {
$this->description = Doc::kt($this->description ?? '', true);
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
type: $data['type']['names'][0] ?? null,
description: $data['description'] ?? null,
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
];
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
/**
* Documentation for a single Vue emittable event
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Event
{
public function __construct(
public string $name,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $since = null,
public array $properties = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
since: $data['tags']['since'][0]['description'] ?? null,
properties: A::map(
$data['properties'] ?? [],
fn ($property) => Argument::factory($property)
)
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'deprecated' => $this->deprecated,
'properties' => $this->properties,
'since' => $this->since,
];
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
/**
* Documentation for a single Vue component method
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Method
{
public function __construct(
public string $name,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $since = null,
public string|null $returns = null,
public array $params = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
since: $data['tags']['since'][0]['description'] ?? null,
returns: $data['returns']['type']['name'] ?? null,
params: A::map(
$data['params'] ?? [],
fn ($param) => Argument::factory($param)
),
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'description' => $this->description,
'deprecated' => $this->deprecated,
'params' => $this->params,
'returns' => $this->returns,
'since' => $this->since,
];
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Documentation for a single Vue component prop
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Prop
{
public function __construct(
public string $name,
public string|null $type = null,
public string|null $description = null,
public string|null $default = null,
public string|null $deprecated = null,
public string|null $example = null,
public bool $required = false,
public string|null $since = null,
public string|null $value = null,
public array $values = []
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static|null
{
// filter internal props
if (isset($data['tags']['internal']) === true) {
return null;
}
// filter unset props
if (($type = $data['type']['name'] ?? null) === 'null') {
return null;
}
return new static(
name: $data['name'],
type: $type,
default: self::normalizeDefault($data['defaultValue']['value'] ?? null, $type),
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
example: $data['tags']['example'][0]['description'] ?? null,
required: $data['required'] ?? false,
since: $data['tags']['since'][0]['description'] ?? null,
value: $data['tags']['value'][0]['description'] ?? null,
values: $data['values'] ?? []
);
}
protected static function normalizeDefault(
string|null $default,
string|null $type
): string|null {
if ($default === null) {
// if type is boolean primarily and no default
// value has been set, add `false` as default
// for clarity
if (Str::startsWith($type, 'boolean')) {
return 'false';
}
return null;
}
// normalize longform function
if (preg_match('/function\(\) {.*return (.*);.*}/si', $default, $matches) === 1) {
return $matches[1];
}
// normalize object shorthand function
if (preg_match('/\(\) => \((.*)\)/si', $default, $matches) === 1) {
return $matches[1];
}
// normalize all other defaults from shorthand function
if (preg_match('/\(\) => (.*)/si', $default, $matches) === 1) {
return $matches[1];
}
return $default;
}
public function toArray(): array
{
return [
'name' => $this->name,
'default' => $this->default,
'description' => $this->description,
'deprecated' => $this->deprecated,
'example' => $this->example,
'required' => $this->required,
'since' => $this->since,
'type' => $this->type,
'value' => $this->value,
'values' => $this->values,
];
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Doc;
use Kirby\Toolkit\A;
/**
* Documentation for a single Vue slot
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @internal
* @codeCoverageIgnore
*/
class Slot
{
public function __construct(
public string $name,
public string|null $description = null,
public string|null $deprecated = null,
public string|null $since = null,
public array $bindings = [],
) {
$this->description = Doc::kt($this->description ?? '');
$this->deprecated = Doc::kt($this->deprecated ?? '');
}
public static function factory(array $data): static
{
return new static(
name: $data['name'],
description: $data['description'] ?? null,
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
since: $data['tags']['since'][0]['description'] ?? null,
bindings: A::map(
$data['bindings'] ?? [],
fn ($binding) => Argument::factory($binding)
)
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'bindings' => $this->bindings,
'description' => $this->description,
'deprecated' => $this->deprecated,
'since' => $this->since,
];
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Toolkit\I18n;
/**
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class Button extends Component
{
public function __construct(
public string $component = 'k-button',
public array|null $badge = null,
public string|null $class = null,
public string|bool|null $current = null,
public string|null $dialog = null,
public bool $disabled = false,
public string|null $drawer = null,
public bool|null $dropdown = null,
public string|null $icon = null,
public string|null $link = null,
public bool|string $responsive = true,
public string|null $size = null,
public string|null $style = null,
public string|null $target = null,
public string|array|null $text = null,
public string|null $theme = null,
public string|array|null $title = null,
public string $type = 'button',
public string|null $variant = null,
...$attrs
) {
$this->attrs = $attrs;
}
public function props(): array
{
return [
...parent::props(),
'badge' => $this->badge,
'current' => $this->current,
'dialog' => $this->dialog,
'disabled' => $this->disabled,
'drawer' => $this->drawer,
'dropdown' => $this->dropdown,
'icon' => $this->icon,
'link' => $this->link,
'responsive' => $this->responsive,
'size' => $this->size,
'target' => $this->target,
'text' => I18n::translate($this->text, $this->text),
'theme' => $this->theme,
'title' => I18n::translate($this->title, $this->title),
'type' => $this->type,
'variant' => $this->variant,
];
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Toolkit\I18n;
/**
* View button to create a new language
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguageCreateButton extends ViewButton
{
public function __construct()
{
$user = App::instance()->user();
$permission = $user?->role()->permissions()->for('languages', 'create');
parent::__construct(
dialog: 'languages/create',
disabled: $permission !== true,
icon: 'add',
text: I18n::translate('language.create'),
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Toolkit\I18n;
/**
* View button to delete a language
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguageDeleteButton extends ViewButton
{
public function __construct(Language $language)
{
$user = App::instance()->user();
$permission = $user?->role()->permissions()->for('languages', 'delete');
parent::__construct(
dialog: 'languages/' . $language->id() . '/delete',
disabled: $permission !== true,
icon: 'trash',
title: I18n::translate('delete'),
);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Toolkit\I18n;
/**
* View button to update settings of a language
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguageSettingsButton extends ViewButton
{
public function __construct(Language $language)
{
$user = App::instance()->user();
$permission = $user?->role()->permissions()->for('languages', 'update');
parent::__construct(
dialog: 'languages/' . $language->id() . '/update',
disabled: $permission !== true,
icon: 'cog',
title: I18n::translate('settings'),
);
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\Str;
/**
* View button to switch content translation languages
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class LanguagesDropdown extends ViewButton
{
protected App $kirby;
public function __construct(
ModelWithContent $model
) {
$this->kirby = $model->kirby();
parent::__construct(
component: 'k-languages-dropdown',
model: $model,
class: 'k-languages-dropdown',
icon: 'translate',
// Fiber dropdown endpoint to load options
// only when dropdown is opened
options: $model->panel()->url(true) . '/languages',
responsive: 'text',
text: Str::upper($this->kirby->language()?->code())
);
}
/**
* Returns if any translation other than the current one has unsaved changes
* (the current language has to be handled in `k-languages-dropdown` as its
* state can change dynamically without another backend request)
*/
public function hasDiff(): bool
{
foreach (Languages::ensure() as $language) {
if ($this->kirby->language()?->code() !== $language->code()) {
if ($this->model->version('changes')->exists($language) === true) {
return true;
}
}
}
return false;
}
public function option(Language $language): array
{
$changes = $this->model->version('changes');
return [
'text' => $language->name(),
'code' => $language->code(),
'current' => $language->code() === $this->kirby->language()?->code(),
'default' => $language->isDefault(),
'changes' => $changes->exists($language),
'lock' => $changes->isLocked('*')
];
}
/**
* Options are used in the Fiber dropdown routes
*/
public function options(): array
{
$languages = $this->kirby->languages();
$options = [];
if ($this->kirby->multilang() === false) {
return $options;
}
// add the primary/default language first
if ($default = $languages->default()) {
$options[] = $this->option($default);
$options[] = '-';
$languages = $languages->not($default);
}
// add all secondary languages after the separator
foreach ($languages as $language) {
$options[] = $this->option($language);
}
return $options;
}
public function props(): array
{
return [
...parent::props(),
'hasDiff' => $this->hasDiff()
];
}
public function render(): array|null
{
// hides the language selector when there are less than 2 languages
if ($this->kirby->languages()->count() < 2) {
return null;
}
return parent::render();
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Toolkit\I18n;
/**
* Open view button
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class OpenButton extends ViewButton
{
public function __construct(
public string|null $link,
public string|null $target = '_blank'
) {
parent::__construct(
class: 'k-open-view-button',
icon: 'open',
link: $link,
target: $target,
title: I18n::translate('open')
);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\Page;
use Kirby\Toolkit\I18n;
/**
* Status view button for pages
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PageStatusButton extends ViewButton
{
public function __construct(
Page $page
) {
$status = $page->status();
$blueprint = $page->blueprint()->status()[$status] ?? null;
$disabled = $page->permissions()->cannot('changeStatus');
$text = $blueprint['label'] ?? I18n::translate('page.status.' . $status);
$title = I18n::translate('page.status') . ': ' . $text;
if ($disabled === true) {
$title .= ' (' . I18n::translate('disabled') . ')';
}
parent::__construct(
class: 'k-status-view-button k-page-status-button',
component: 'k-status-view-button',
dialog: $page->panel()->url(true) . '/changeStatus',
disabled: $disabled,
icon: 'status-' . $status,
style: '--icon-size: 15px',
text: $text,
title: $title,
theme: match($status) {
'draft' => 'negative-icon',
'unlisted' => 'info-icon',
'listed' => 'positive-icon'
}
);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Toolkit\I18n;
/**
* Preview view button
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PreviewButton extends ViewButton
{
public function __construct(
public string|null $link
) {
parent::__construct(
class: 'k-preview-view-button',
icon: 'window',
link: $link,
title: I18n::translate('preview')
);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\ModelWithContent;
use Kirby\Toolkit\I18n;
/**
* Settings view button for models
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class SettingsButton extends ViewButton
{
public function __construct(
ModelWithContent $model
) {
parent::__construct(
component: 'k-settings-view-button',
class: 'k-settings-view-button',
icon: 'cog',
options: $model->panel()->url(true),
title: I18n::translate('settings'),
);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\ModelWithContent;
use Kirby\Content\VersionId;
use Kirby\Toolkit\I18n;
/**
* Versions view button for models
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VersionsButton extends ViewButton
{
public function __construct(
ModelWithContent $model,
VersionId|string $versionId = 'latest'
) {
$versionId = $versionId === 'compare' ? 'compare' : VersionId::from($versionId)->value();
$viewUrl = $model->panel()->url(true) . '/preview';
parent::__construct(
class: 'k-versions-view-button',
icon: $versionId === 'compare' ? 'layout-columns' : 'git-branch',
options: [
[
'label' => I18n::translate('version.latest'),
'icon' => 'git-branch',
'link' => $viewUrl . '/latest',
'current' => $versionId === 'latest'
],
[
'label' => I18n::translate('version.changes'),
'icon' => 'git-branch',
'link' => $viewUrl . '/changes',
'current' => $versionId === 'changes'
],
'-',
[
'label' => I18n::translate('version.compare'),
'icon' => 'layout-columns',
'link' => $viewUrl . '/compare',
'current' => $versionId === 'compare'
],
],
text: I18n::translate('version.' . $versionId),
);
}
}

View file

@ -0,0 +1,215 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Closure;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Panel\Panel;
use Kirby\Panel\Ui\Button;
use Kirby\Toolkit\Controller;
/**
* A view button is a UI button, by default small in size and filles,
* that optionally defines options for a dropdown
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class ViewButton extends Button
{
public function __construct(
public string $component = 'k-view-button',
public readonly ModelWithContent|Language|null $model = null,
public array|null $badge = null,
public string|null $class = null,
public string|bool|null $current = null,
public string|null $dialog = null,
public bool $disabled = false,
public string|null $drawer = null,
public bool|null $dropdown = null,
public string|null $icon = null,
public string|null $link = null,
public array|string|null $options = null,
public bool|string $responsive = true,
public string|null $size = 'sm',
public string|null $style = null,
public string|null $target = null,
public string|array|null $text = null,
public string|null $theme = null,
public string|array|null $title = null,
public string $type = 'button',
public string|null $variant = 'filled',
...$attrs
) {
$this->attrs = $attrs;
}
/**
* Creates new view button by looking up
* the button in all areas, if referenced by name
* and resolving to proper instance
*/
public static function factory(
string|array|Closure|bool $button = true,
string|int|null $name = null,
string|null $view = null,
ModelWithContent|Language|null $model = null,
array $data = []
): static|null {
// if referenced by name (`name: false`),
// don't render anything
if ($button === false) {
return null;
}
// transform `- name` notation to `name: true`
if (
is_string($name) === false &&
is_string($button) === true
) {
$name = $button;
$button = true;
}
// if referenced by name (`name: true`),
// try to get button definition from areas or config
if ($button === true) {
$button = static::find($name, $view);
}
// resolve Closure to button object or array
if ($button instanceof Closure) {
$button = static::resolve($button, $model, $data);
}
if (
$button === null ||
$button instanceof ViewButton
) {
return $button;
}
// flatten array into list of arguments for this class
$button = static::normalize($button);
// if button definition has a name, use it for the component name
if (is_string($name) === true) {
// if this specific component does not exist,
// `k-view-buttons` will fall back to `k-view-button` again
$button['component'] ??= 'k-' . $name . '-view-button';
}
return new static(...$button, model: $model);
}
/**
* Finds a view button by name
* among the defined buttons from all areas
* @unstable
*/
public static function find(
string $name,
string|null $view = null
): array|Closure {
// collect all buttons from areas and config
$buttons = [
...Panel::buttons(),
...App::instance()->option('panel.viewButtons.' . $view, [])
];
// try to find by full name (view-prefixed)
if ($view && $button = $buttons[$view . '.' . $name] ?? null) {
return $button;
}
// try to find by just name
if ($button = $buttons[$name] ?? null) {
return $button;
}
// assume it must be a custom view button component
return ['component' => 'k-' . $name . '-view-button'];
}
/**
* Transforms an array to be used as
* named arguments in the constructor
* @unstable
*/
public static function normalize(array $button): array
{
// if component and props are both not set, assume shortcut
// where props were directly passed on top-level
if (
isset($button['component']) === false &&
isset($button['props']) === false
) {
return $button;
}
// flatten array
if ($props = $button['props'] ?? null) {
$button = [...$props, ...$button];
unset($button['props']);
}
return $button;
}
public function props(): array
{
// helper for props that support Kirby queries
$resolve = fn ($value) =>
$value ?
$this->model?->toSafeString($value) ?? $value :
null;
return [
...$props = parent::props(),
'dialog' => $resolve($props['dialog']),
'drawer' => $resolve($props['drawer']),
'icon' => $resolve($props['icon']),
'link' => $resolve($props['link']),
'text' => $resolve($props['text']),
'theme' => $resolve($props['theme']),
'options' => $this->options
];
}
/**
* Transforms a closure to the actual view button
* by calling it with the provided arguments
*/
public static function resolve(
Closure $button,
ModelWithContent|Language|null $model = null,
array $data = []
): static|array|null {
$kirby = App::instance();
$controller = new Controller($button);
if (
$model instanceof ModelWithContent ||
$model instanceof Language
) {
$data = [
'model' => $model,
$model::CLASS_ALIAS => $model,
...$data
];
}
return $controller->call(data: [
'kirby' => $kirby,
'site' => $kirby->site(),
'user' => $kirby->user(),
...$data
]);
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace Kirby\Panel\Ui\Buttons;
use Kirby\Cms\App;
use Kirby\Cms\Language;
use Kirby\Cms\ModelWithContent;
use Kirby\Panel\Model;
/**
* Collects view buttons for a specific view
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class ViewButtons
{
public function __construct(
public readonly string $view,
public readonly ModelWithContent|Language|null $model = null,
public array|false|null $buttons = null,
public array $data = []
) {
// if no specific buttons are passed,
// use default buttons for this view from config
$this->buttons ??= App::instance()->option(
'panel.viewButtons.' . $view
);
}
/**
* Adds data passed to view button closures
*
* @return $this
*/
public function bind(array $data): static
{
$this->data = [...$this->data, ...$data];
return $this;
}
/**
* Sets the default buttons
*
* @return $this
*/
public function defaults(string ...$defaults): static
{
$this->buttons ??= $defaults;
return $this;
}
/**
* Returns array of button component-props definitions
*/
public function render(): array
{
// hides all buttons when `buttons: false` set
if ($this->buttons === false) {
return [];
}
$buttons = [];
foreach ($this->buttons ?? [] as $name => $button) {
$buttons[] = ViewButton::factory(
button: $button,
name: $name,
view: $this->view,
model: $this->model,
data: $this->data
)?->render();
}
return array_values(array_filter($buttons));
}
/**
* Creates new instance for a view
* with special support for model views
*/
public static function view(
string|Model $view,
ModelWithContent|Language|null $model = null
): static {
if ($view instanceof Model) {
$model = $view->model();
$blueprint = $model->blueprint()->buttons();
$view = $model::CLASS_ALIAS;
}
return new static(
view: $view,
model: $model ?? null,
buttons: $blueprint ?? null
);
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Exception\LogicException;
use Kirby\Toolkit\Str;
/**
* Component that can be passed as component-props array
* to the Vue Panel frontend
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
abstract class Component
{
protected string $key;
public array $attrs = [];
public function __construct(
public string $component,
public string|null $class = null,
public string|null $style = null,
...$attrs
) {
$this->attrs = $attrs;
}
/**
* Magic setter and getter for component properties
*
* ```php
* $component->class('my-class')
* ```
*/
public function __call(string $name, array $args = [])
{
if (property_exists($this, $name) === false) {
throw new LogicException(
message: 'The property "' . $name . '" does not exist on the UI component "' . $this->component . '"'
);
}
// getter
if ($args === []) {
return $this->$name;
}
// setter
$this->$name = $args[0];
return $this;
}
/**
* Returns a (unique) key that can be used
* for Vue's `:key` attribute
*/
public function key(): string
{
return $this->key ??= Str::random(10, 'alphaNum');
}
/**
* Returns the props that will be passed to the Vue component
*/
public function props(): array
{
return [
'class' => $this->class,
'style' => $this->style,
...$this->attrs
];
}
/**
* Returns array with the Vue component name and props array
*/
public function render(): array|null
{
return [
'component' => $this->component,
'key' => $this->key(),
'props' => array_filter($this->props())
];
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace Kirby\Panel\Ui;
use Kirby\Cms\App;
use Kirby\Cms\File;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Panel\Ui\FilePreviews\DefaultFilePreview;
use Kirby\Toolkit\I18n;
/**
* Defines a component that implements a file preview
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
abstract class FilePreview extends Component
{
public function __construct(
public File $file,
public string $component
) {
}
/**
* Returns true if this class should
* handle the preview of this file
*/
abstract public static function accepts(File $file): bool;
/**
* Returns detail information about the file
*/
public function details(): array
{
return [
[
'title' => I18n::translate('template'),
'text' => $this->file->template() ?? '—'
],
[
'title' => I18n::translate('mime'),
'text' => $this->file->mime()
],
[
'title' => I18n::translate('url'),
'link' => $link = $this->file->previewUrl(),
'text' => $link,
],
[
'title' => I18n::translate('size'),
'text' => $this->file->niceSize()
],
];
}
/**
* Returns a file preview instance by going through all
* available handler classes and finding the first that
* accepts the file
*/
final public static function factory(File $file): static
{
// get file preview classes providers from plugins
$handlers = App::instance()->extensions('filePreviews');
foreach ($handlers as $handler) {
if (is_subclass_of($handler, self::class) === false) {
throw new InvalidArgumentException(
message: 'File preview handler "' . $handler . '" must extend ' . self::class
);
}
if ($handler::accepts($file) === true) {
return new $handler($file);
}
}
return new DefaultFilePreview($file);
}
/**
* Icon or image to display as thumbnail
*/
public function image(): array|null
{
return $this->file->panel()->image([
'back' => 'transparent',
'ratio' => '1/1'
], 'cards');
}
public function props(): array
{
return [
'details' => $this->details(),
'image' => $this->image(),
'url' => $this->file->previewUrl()
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class AudioFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-audio-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->type() === 'audio';
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* Fallback file preview component
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class DefaultFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-default-file-preview'
) {
}
/**
* Accepts any file as last resort
*/
public static function accepts(File $file): bool
{
return true;
}
public function props(): array
{
return [
...parent::props(),
'image' => $this->image()
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
use Kirby\Toolkit\I18n;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class ImageFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-image-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->type() === 'image';
}
public function details(): array
{
return [
...parent::details(),
[
'title' => I18n::translate('dimensions'),
'text' => $this->file->dimensions() . ' ' . I18n::translate('pixel')
],
[
'title' => I18n::translate('orientation'),
'text' => I18n::translate('orientation.' . $this->file->dimensions()->orientation())
]
];
}
public function props(): array
{
return [
...parent::props(),
'focusable' => $this->file->panel()->isFocusable()
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class PdfFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-pdf-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->extension() === 'pdf';
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Kirby\Panel\Ui\FilePreviews;
use Kirby\Cms\File;
use Kirby\Panel\Ui\FilePreview;
/**
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
* @unstable
*/
class VideoFilePreview extends FilePreview
{
public function __construct(
public File $file,
public string $component = 'k-video-file-preview'
) {
}
public static function accepts(File $file): bool
{
return $file->type() === 'video';
}
}

124
kirby/src/Plugin/Asset.php Normal file
View file

@ -0,0 +1,124 @@
<?php
namespace Kirby\Plugin;
use Kirby\Filesystem\F;
use Stringable;
/**
* Representing a plugin asset with methods
* to manage the asset file between the plugin
* and media folder
*
* @package Kirby Plugin
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Asset implements Stringable
{
public function __construct(
protected string $path,
protected string $root,
protected Plugin $plugin
) {
}
public function extension(): string
{
return F::extension($this->path());
}
public function filename(): string
{
return F::filename($this->path());
}
/**
* Create a unique media hash
*/
public function mediaHash(): string
{
return crc32($this->filename()) . '-' . $this->modified();
}
/**
* Absolute path to the asset file in the media folder
*/
public function mediaRoot(): string
{
return $this->plugin()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->path();
}
/**
* Public accessible url path for the asset
*/
public function mediaUrl(): string
{
return $this->plugin()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->path();
}
/**
* Timestamp when asset file was last modified
*/
public function modified(): int|false
{
return F::modified($this->root());
}
public function path(): string
{
return $this->path;
}
public function plugin(): Plugin
{
return $this->plugin;
}
/**
* Publishes the asset file to the plugin's media folder
* by creating a symlink
*/
public function publish(): void
{
F::link($this->root(), $this->mediaRoot(), 'symlink');
}
/**
* @internal
* @since 4.0.0
* @deprecated 4.0.0
* @codeCoverageIgnore
*/
public function publishAt(string $path): void
{
F::link(
$this->root(),
$this->plugin()->mediaRoot() . '/' . $path,
'symlink'
);
}
public function root(): string
{
return $this->root;
}
/**
* @see self::mediaUrl()
*/
public function url(): string
{
return $this->mediaUrl();
}
/**
* @see self::url()
*/
public function __toString(): string
{
return $this->url();
}
}

188
kirby/src/Plugin/Assets.php Normal file
View file

@ -0,0 +1,188 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Kirby\Cms\App;
use Kirby\Cms\Collection;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
use Kirby\Toolkit\Str;
/**
* Plugin assets are automatically copied/linked
* to the media folder, to make them publicly
* available. This class handles the magic around that.
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*
* @extends \Kirby\Cms\Collection<\Kirby\Plugin\Asset>
*/
class Assets extends Collection
{
/**
* Clean old/deprecated assets on every resolve
*/
public static function clean(string $pluginName): void
{
if ($plugin = App::instance()->plugin($pluginName)) {
$media = $plugin->mediaRoot();
$assets = $plugin->assets();
// get all media files
$files = Dir::index($media, true);
// get all active assets' paths from the plugin
$active = $assets->values(
function ($asset) {
$path = $asset->mediaHash() . '/' . $asset->path();
$paths = [];
$parts = explode('/', $path);
// collect all path segments
// (e.g. foo/, foo/bar/, foo/bar/baz.css) for the asset
for ($i = 1, $max = count($parts); $i <= $max; $i++) {
$paths[] = implode('/', array_slice($parts, 0, $i));
// TODO: remove when media hash is enforced as mandatory
$paths[] = implode('/', array_slice($parts, 1, $i));
}
return $paths;
}
);
// flatten the array and remove duplicates
$active = array_unique(array_merge(...array_values($active)));
// get outdated media files by comparing all
// files in the media folder against the set of asset paths
$stale = array_diff($files, $active);
foreach ($stale as $file) {
$root = $media . '/' . $file;
if (is_file($root) === true) {
F::remove($root);
} else {
Dir::remove($root);
}
}
}
}
/**
* Filters assets collection by CSS files
*/
public function css(): static
{
return $this->filter(fn ($asset) => $asset->extension() === 'css');
}
/**
* Creates a new collection for the plugin's assets
* by considering the plugin's `asset` extension
* (and `assets` directory as fallback)
*/
public static function factory(Plugin $plugin): static
{
// get assets defined in the plugin extension
if ($assets = $plugin->extends()['assets'] ?? null) {
if ($assets instanceof Closure) {
$assets = $assets();
}
// normalize array: use relative path as
// key when no key is defined
foreach ($assets as $key => $root) {
if (is_int($key) === true) {
unset($assets[$key]);
$path = Str::after($root, $plugin->root() . '/');
$assets[$path] = $root;
}
}
}
// fallback: if no assets are defined in the plugin extension,
// use all files in the plugin's `assets` directory
if ($assets === null) {
$assets = [];
$root = $plugin->root() . '/assets';
foreach (Dir::index($root, true) as $path) {
if (is_file($root . '/' . $path) === true) {
$assets[$path] = $root . '/' . $path;
}
}
}
$collection = new static([], $plugin);
foreach ($assets as $path => $root) {
$collection->data[$path] = new Asset($path, $root, $plugin);
}
return $collection;
}
/**
* Filters assets collection by JavaScript files
*/
public function js(): static
{
return $this->filter(fn ($asset) => $asset->extension() === 'js');
}
public function plugin(): Plugin
{
return $this->parent;
}
/**
* Create a symlink for a plugin asset and
* return the public URL
*/
public static function resolve(
string $pluginName,
string $hash,
string $path
): Response|null {
if ($plugin = App::instance()->plugin($pluginName)) {
// do some spring cleaning for older files
static::clean($pluginName);
// @codeCoverageIgnoreStart
// TODO: deprecated media URL without hash
if (empty($hash) === true) {
$asset = $plugin->asset($path);
$asset->publishAt($path);
return Response::file($asset->root());
}
// TODO: deprecated media URL with hash (but path)
if ($asset = $plugin->asset($hash . '/' . $path)) {
$asset->publishAt($hash . '/' . $path);
return Response::file($asset->root());
}
// @codeCoverageIgnoreEnd
if ($asset = $plugin->asset($path)) {
if ($asset->mediaHash() === $hash) {
// create a symlink if possible
$asset->publish();
// return the file response
return Response::file($asset->root());
}
}
}
return null;
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Stringable;
/**
* Represents the license of a plugin.
* Used to display the license in the Panel system view
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class License implements Stringable
{
protected LicenseStatus $status;
public function __construct(
protected Plugin $plugin,
protected string $name,
protected string|null $link = null,
LicenseStatus|null $status = null
) {
$this->status = $status ?? LicenseStatus::from('unknown');
}
/**
* Returns the string representation of the license
*/
public function __toString(): string
{
return $this->name();
}
/**
* Creates a license instance from a given value
*/
public static function from(
Plugin $plugin,
Closure|array|string|null $license
): static {
if ($license instanceof Closure) {
return $license($plugin);
}
if (is_array($license)) {
return new static(
plugin: $plugin,
name: $license['name'] ?? '',
link: $license['link'] ?? null,
status: LicenseStatus::from($license['status'] ?? 'active')
);
}
if ($license === null || $license === '-') {
return new static(
plugin: $plugin,
name: '-',
status: LicenseStatus::from('unknown')
);
}
return new static(
plugin: $plugin,
name: $license,
status: LicenseStatus::from('active')
);
}
/**
* Get the license link. This can be the
* license terms or a link to a shop to
* purchase a license.
*/
public function link(): string|null
{
return $this->link;
}
/**
* Get the license name
*/
public function name(): string
{
return $this->name;
}
/**
* Get the license status
*/
public function status(): LicenseStatus
{
return $this->status;
}
/**
* Returns the license information as an array
*/
public function toArray(): array
{
return [
'link' => $this->link(),
'name' => $this->name(),
'status' => $this->status()->toArray()
];
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace Kirby\Plugin;
use Kirby\Cms\LicenseStatus as SystemLicenseStatus;
use Stringable;
/**
* Represents the license status of a plugin.
* Used to display the status in the Panel system view
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
* @since 5.0.0
*/
class LicenseStatus implements Stringable
{
public function __construct(
protected string $value,
protected string $icon,
protected string $label,
protected string|null $link = null,
protected string|null $dialog = null,
protected string|null $drawer = null,
protected string|null $theme = null
) {
}
/**
* Returns the status label
*/
public function __toString(): string
{
return $this->label();
}
/**
* Returns the status dialog
*/
public function dialog(): string|null
{
return $this->dialog;
}
/**
* Returns the status drawer
*/
public function drawer(): string|null
{
return $this->drawer;
}
/**
* Returns a status by its name
*/
public static function from(LicenseStatus|string|array|null $status): static
{
if ($status instanceof LicenseStatus) {
return $status;
}
if (is_array($status) === true) {
return new static(...$status);
}
$status = SystemLicenseStatus::from($status ?? 'unknown');
$status ??= SystemLicenseStatus::Unknown;
return new static(
value: $status->value,
icon: $status->icon(),
label: $status->label(),
theme: $status->theme()
);
}
/**
* Returns the status icon
*/
public function icon(): string
{
return $this->icon;
}
/**
* Returns the status label
*/
public function label(): string
{
return $this->label;
}
/**
* Returns the status link
*/
public function link(): string|null
{
return $this->link;
}
/**
* Returns the theme
*/
public function theme(): string|null
{
return $this->theme;
}
/**
* Returns the status information as an array
*/
public function toArray(): array
{
return [
'dialog' => $this->dialog(),
'drawer' => $this->drawer(),
'icon' => $this->icon(),
'label' => $this->label(),
'link' => $this->link(),
'theme' => $this->theme(),
'value' => $this->value(),
];
}
/**
* Returns the status value
*/
public function value(): string
{
return $this->value;
}
}

354
kirby/src/Plugin/Plugin.php Normal file
View file

@ -0,0 +1,354 @@
<?php
namespace Kirby\Plugin;
use Closure;
use Composer\InstalledVersions;
use Kirby\Cms\App;
use Kirby\Cms\Helpers;
use Kirby\Cms\System\UpdateStatus;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
use Throwable;
/**
* Represents a Plugin and handles parsing of
* the composer.json. It also creates the prefix
* and media url for the plugin.
*
* @package Kirby Plugin
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Plugin
{
protected Assets $assets;
protected License|Closure|array|string $license;
protected UpdateStatus|null $updateStatus = null;
/**
* @param string $name Plugin name within Kirby (`vendor/plugin`)
* @param array $extends Associative array of plugin extensions
*
* @throws \Kirby\Exception\InvalidArgumentException If the plugin name has an invalid format
*/
public function __construct(
protected string $name,
protected array $extends = [],
protected array $info = [],
Closure|string|array|null $license = null,
protected string|null $root = null,
protected string|null $version = null,
) {
static::validateName($name);
// TODO: Remove in v7
if ($root = $extends['root'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing the `root` inside the `extends` array has been deprecated. Pass it directly as named argument `root`.', 'plugin-extends-root');
$this->root ??= $root;
unset($this->extends['root']);
}
$this->root ??= dirname(debug_backtrace()[0]['file']);
// TODO: Remove in v7
if ($info = $extends['info'] ?? null) {
Helpers::deprecated('Plugin "' . $name . '": Passing an `info` array inside the `extends` array has been deprecated. Pass the individual entries directly as named `info` argument.', 'plugin-extends-root');
if (empty($info) === false && is_array($info) === true) {
$this->info = [...$info, ...$this->info];
}
unset($this->extends['info']);
}
// read composer.json and use as info fallback
$info = Data::read($this->manifest(), fail: false);
$this->info = [...$info, ...$this->info];
$this->license = $license ?? $this->info['license'] ?? '-';
}
/**
* Allows access to any composer.json field by method call
*/
public function __call(string $key, array|null $arguments = null): mixed
{
return $this->info()[$key] ?? null;
}
/**
* Returns the plugin asset object for a specific asset
*/
public function asset(string $path): Asset|null
{
return $this->assets()->get($path);
}
/**
* Returns the plugin assets collection
*/
public function assets(): Assets
{
return $this->assets ??= Assets::factory($this);
}
/**
* Returns the array with author information
* from the composer.json file
*/
public function authors(): array
{
return $this->info()['authors'] ?? [];
}
/**
* Returns a comma-separated list with all author names
*/
public function authorsNames(): string
{
$names = [];
foreach ($this->authors() as $author) {
$names[] = $author['name'] ?? null;
}
return implode(', ', array_filter($names));
}
/**
* Returns the associative array of extensions the plugin bundles
*/
public function extends(): array
{
return $this->extends;
}
/**
* Returns the unique ID for the plugin
* (alias for the plugin name)
*/
public function id(): string
{
return $this->name();
}
/**
* Returns the info data (from composer.json)
*/
public function info(): array
{
return $this->info;
}
/**
* Current $kirby instance
*/
public function kirby(): App
{
return App::instance();
}
/**
* Returns the link to the plugin homepage
*/
public function link(): string|null
{
$info = $this->info();
$homepage = $info['homepage'] ?? null;
$docs = $info['support']['docs'] ?? null;
$source = $info['support']['source'] ?? null;
$link = $homepage ?? $docs ?? $source;
return V::url($link) ? $link : null;
}
/**
* Returns the license object
*/
public function license(): License
{
// resolve license info from Closure, array or string
return License::from(
plugin: $this,
license: $this->license
);
}
/**
* Returns the path to the plugin's composer.json
*/
public function manifest(): string
{
return $this->root() . '/composer.json';
}
/**
* Returns the root where plugin assets are copied to
*/
public function mediaRoot(): string
{
return $this->kirby()->root('media') . '/plugins/' . $this->name();
}
/**
* Returns the base URL for plugin assets
*/
public function mediaUrl(): string
{
return $this->kirby()->url('media') . '/plugins/' . $this->name();
}
/**
* Returns the plugin name (`vendor/plugin`)
*/
public function name(): string
{
return $this->name;
}
/**
* Returns a Kirby option value for this plugin
*/
public function option(string $key)
{
return $this->kirby()->option($this->prefix() . '.' . $key);
}
/**
* Returns the option prefix (`vendor.plugin`)
*/
public function prefix(): string
{
return str_replace('/', '.', $this->name());
}
/**
* Returns the root where the plugin files are stored
*/
public function root(): string
{
return $this->root;
}
/**
* Returns all available plugin metadata
*/
public function toArray(): array
{
return [
'authors' => $this->authors(),
'description' => $this->description(),
'name' => $this->name(),
'license' => $this->license()->toArray(),
'link' => $this->link(),
'root' => $this->root(),
'version' => $this->version()
];
}
/**
* Returns the update status object unless the
* update check has been disabled for the plugin
* @since 3.8.0
*
* @param array|null $data Custom override for the getkirby.com update data
*/
public function updateStatus(array|null $data = null): UpdateStatus|null
{
if ($this->updateStatus !== null) {
return $this->updateStatus;
}
$kirby = $this->kirby();
$option = $kirby->option('updates.plugins');
// specific configuration per plugin
if (is_array($option) === true) {
// filter all option values by glob match
$option = A::filter(
$option,
fn ($value, $key) => fnmatch($key, $this->name()) === true
);
// sort the matches by key length (with longest key first)
$keys = array_map('strlen', array_keys($option));
array_multisort($keys, SORT_DESC, $option);
if ($option !== []) {
// use the first and therefore longest key (= most specific match)
$option = reset($option);
} else {
// fallback to the default option value
$option = true;
}
}
$option ??= $kirby->option('updates') ?? true;
if ($option !== true) {
return null;
}
return $this->updateStatus = new UpdateStatus($this, false, $data);
}
/**
* Checks if the name follows the required pattern
* and throws an exception if not
*
* @throws \Kirby\Exception\InvalidArgumentException
*/
public static function validateName(string $name): void
{
if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) {
throw new InvalidArgumentException(
message: 'The plugin name must follow the format "a-z0-9-/a-z0-9-"'
);
}
}
/**
* Returns the normalized version number
* from the composer.json file
*/
public function version(): string|null
{
$name = $this->info()['name'] ?? null;
try {
// try to get version from "vendor/composer/installed.php",
// this is the most reliable source for the version
$version = InstalledVersions::getPrettyVersion($name);
} catch (Throwable) {
$version = null;
}
// fallback to the version provided in the plugin's index.php: as named
// argument, entry in the info array or from the composer.json file
$version ??= $this->version ?? $this->info()['version'] ?? null;
if (
is_string($version) !== true ||
$version === '' ||
Str::endsWith($version, '+no-version-set')
) {
return null;
}
// normalize the version number to be without leading `v`
$version = ltrim($version, 'vV');
// ensure that the version number now starts with a digit
if (preg_match('/^[0-9]/', $version) !== 1) {
return null;
}
return $version;
}
}

17
node_modules/.bin/acorn.cmd generated vendored Normal file
View file

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\acorn\bin\acorn" %*

28
node_modules/.bin/acorn.ps1 generated vendored Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../acorn/bin/acorn" $args
} else {
& "$basedir/node$exe" "$basedir/../acorn/bin/acorn" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../acorn/bin/acorn" $args
} else {
& "node$exe" "$basedir/../acorn/bin/acorn" $args
}
$ret=$LASTEXITCODE
}
exit $ret

17
node_modules/.bin/gulp.cmd generated vendored Normal file
View file

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\gulp\bin\gulp.js" %*

28
node_modules/.bin/gulp.ps1 generated vendored Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../gulp/bin/gulp.js" $args
} else {
& "$basedir/node$exe" "$basedir/../gulp/bin/gulp.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../gulp/bin/gulp.js" $args
} else {
& "node$exe" "$basedir/../gulp/bin/gulp.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

17
node_modules/.bin/resolve.cmd generated vendored Normal file
View file

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\resolve\bin\resolve" %*

28
node_modules/.bin/resolve.ps1 generated vendored Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../resolve/bin/resolve" $args
} else {
& "$basedir/node$exe" "$basedir/../resolve/bin/resolve" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../resolve/bin/resolve" $args
} else {
& "node$exe" "$basedir/../resolve/bin/resolve" $args
}
$ret=$LASTEXITCODE
}
exit $ret

17
node_modules/.bin/semver.cmd generated vendored Normal file
View file

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\semver\bin\semver.js" %*

28
node_modules/.bin/semver.ps1 generated vendored Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../semver/bin/semver.js" $args
} else {
& "$basedir/node$exe" "$basedir/../semver/bin/semver.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../semver/bin/semver.js" $args
} else {
& "node$exe" "$basedir/../semver/bin/semver.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret

17
node_modules/.bin/terser.cmd generated vendored Normal file
View file

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\terser\bin\terser" %*

28
node_modules/.bin/terser.ps1 generated vendored Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../terser/bin/terser" $args
} else {
& "$basedir/node$exe" "$basedir/../terser/bin/terser" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../terser/bin/terser" $args
} else {
& "node$exe" "$basedir/../terser/bin/terser" $args
}
$ret=$LASTEXITCODE
}
exit $ret

17
node_modules/.bin/which.cmd generated vendored Normal file
View file

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\which\bin\which" %*

28
node_modules/.bin/which.ps1 generated vendored Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../which/bin/which" $args
} else {
& "$basedir/node$exe" "$basedir/../which/bin/which" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../which/bin/which" $args
} else {
& "node$exe" "$basedir/../which/bin/which" $args
}
$ret=$LASTEXITCODE
}
exit $ret

View file

@ -0,0 +1,22 @@
name: Liste de grands liens
icon: list-bullet
preview: fields
wysiwyg: true
fields:
items:
label: false
type: structure
fields:
text:
type: text
label: texte
width: 1/2
link:
label: Lien
type: link
required: true
options:
- url
- page
- file
width: 1/2

View file

@ -34,6 +34,7 @@ tabs:
- text - text
- image - image
- links-list - links-list
- big-links-list
settings: settings:
fields: fields:
imgHeight: imgHeight:

View file

@ -7,16 +7,31 @@ tabs:
columns: columns:
- width: 1/1 - width: 1/1
fields: fields:
infoBannerHeading: infoBannerHeading:
type: headline type: headline
label: Bannière d'infos label: Bannière d'infos
infoBannerEnabled:
label: Afficher la bannière d'infos
type: toggle
text:
- Non
- Oui
default: false
infoBannerColor: infoBannerColor:
label: Couleur de fond label: Couleur de fond
extends: fields/color extends: fields/color
when:
infoBannerEnabled: true
infoBanner: infoBanner:
label: Messages label: Messages
type: structure type: structure
help: Affichée en haut de la page d'accueil. help: Affichée en haut de la page d'accueil.
when:
infoBannerEnabled: true
fields: fields:
message: message:
label: Message label: Message
@ -29,8 +44,12 @@ tabs:
options: options:
- url - url
- page - page
separator1: separator1:
type: line type: line
when:
infoBannerEnabled: true
- width: 1/1 - width: 1/1
fields: fields:
heroHeadline: heroHeadline:
@ -46,38 +65,37 @@ tabs:
options: options:
event: Événement event: Événement
custom: Personnalisé custom: Personnalisé
heroText: heroTextLeft:
label: Text label: Texte gauche
type: blocks type: blocks
when: when:
heroMode: custom heroMode: custom
fieldsets: fieldsets:
- hero-heading - hero-heading
- hero-text - hero-text
heroLinkUrl: - width: 1/2
fields:
heroEvent:
label: Spectacle
type: pages
multiple: false
query: page('programme').grandChildren
when:
heroMode: event
heroTextRight:
label: Texte droite
type: writer
when:
heroMode: custom
heroLink:
label: Lien label: Lien
type: link type: link
width: 1/2
options: options:
- url - url
- page - page
help: Optionnel. Laisser vide pour ne pas afficher de lien dans le hero. help: Optionnel. Laisser vide pour ne pas afficher de lien dans le hero.
when: when:
heroMode: custom heroMode: custom
heroLinkText:
label: Texte du Lien
type: text
width: 1/2
when:
heroMode: custom
- width: 1/2
fields:
heroEvent:
label: Événement
type: pages
query: page('programme').grandChildren
when:
heroMode: event
heroImage: heroImage:
label: Image label: Image
type: files type: files

View file

@ -12,7 +12,7 @@ class EventPage extends Page {
$firstSession = $sessions->first()->date(); $firstSession = $sessions->first()->date();
$lastSession = $sessions->last()->date(); $lastSession = $sessions->last()->date();
} catch (\Throwable $th) { } catch (\Throwable $th) {
throw new Exception('Can\'t define sessions for event "' . $this->title()->value() . '".', 1); return '';
} }
$startDay = intval($firstSession->toDate('d')); $startDay = intval($firstSession->toDate('d'));

Some files were not shown because too many files have changed in this diff Show more