Initial switchable view for image list.

This commit is contained in:
Josh Stark 2019-12-14 18:05:10 +00:00
parent e0c9317225
commit 11798d6464
14 changed files with 475 additions and 121 deletions

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2019 LinuxServer.io
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.linuxserver.fleet.v2.client.docker;
public abstract class AbstractDockerApiClient implements DockerApiClient {
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2019 LinuxServer.io
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.linuxserver.fleet.v2.client.docker;
public interface DockerApiClient {
<RESPONSE, REQUEST extends DockerApiRequest<RESPONSE>> RESPONSE executeRequest(REQUEST request);
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2019 LinuxServer.io
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.linuxserver.fleet.v2.client.docker;
public interface DockerApiRequest<RESPONSE_TYPE> {
RESPONSE_TYPE performRequest();
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2019 LinuxServer.io
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.linuxserver.fleet.v2.client.docker.converter;
public interface DockerResponseConverter<DOCKER_MODEL, INTERNAL_MODEL> {
INTERNAL_MODEL convert(final DOCKER_MODEL dockerModel);
}

View File

@ -31,6 +31,8 @@ import java.util.TreeSet;
public class Image extends AbstractSyncItem<ImageKey, Image> {
private static final DateTimeFormatter DefaultImageTimeFormat = DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm:ss");
private final ImageCountData countData;
private final String description;
private final LocalDateTime lastUpdated;
@ -102,7 +104,7 @@ public class Image extends AbstractSyncItem<ImageKey, Image> {
public final String getLastUpdatedAsString() {
final LocalDateTime lastUpdated = parseDateTime(this.lastUpdated);
return null == lastUpdated ? null : DateTimeFormatter.ISO_DATE_TIME.format(lastUpdated);
return null == lastUpdated ? null : DefaultImageTimeFormat.format(lastUpdated);
}
public final List<TagBranch> getTagBranches() {

View File

@ -23,8 +23,6 @@ import io.linuxserver.fleet.v2.service.RepositoryManager;
import io.linuxserver.fleet.v2.types.Repository;
import io.linuxserver.fleet.v2.web.PageModelSpec;
import java.util.Collections;
public class HomeController extends AbstractPageHandler {
private final RepositoryManager repositoryManager;

View File

@ -0,0 +1,52 @@
/*
* Copyright (c) 2019 LinuxServer.io
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.linuxserver.fleet.v2.web.routes;
import io.javalin.http.Context;
import io.linuxserver.fleet.v2.key.ImageLookupKey;
import io.linuxserver.fleet.v2.service.RepositoryManager;
import io.linuxserver.fleet.v2.web.PageModelSpec;
public class ImageController extends AbstractPageHandler {
private final RepositoryManager repositoryManager;
public ImageController(final RepositoryManager repositoryManager) {
this.repositoryManager = repositoryManager;
}
@Override
protected PageModelSpec handlePageLoad(final Context ctx) {
final String imageLookupParam = ctx.queryParam("name");
if (null != imageLookupParam) {
final PageModelSpec modelSpec = new PageModelSpec("views/pages/image.ftl");
modelSpec.addModelAttribute("image", repositoryManager.lookupImage(new ImageLookupKey(imageLookupParam)));
return modelSpec;
} else {
return new PageModelSpec("views/pages/not-found.ftl");
}
}
@Override
protected PageModelSpec handleFormSubmission(final Context ctx) {
return new PageModelSpec("views/pages/unsupported.ftl");
}
}

View File

@ -158,4 +158,17 @@ input:checked + .slider:before {
table tbody th.is-half {
width: 50%;
}
table tbody tr td.is-vcentered {
vertical-align: middle;
}
#ImageTable tr td {
white-space: nowrap;
}
table tr th.tablesorter-header {
outline: none;
cursor: pointer;
}

View File

@ -19,49 +19,36 @@
<#import "../ui/components/message.ftl" as message />
<#import "../ui/elements/box.ftl" as box />
<#import "../prebuilt/base.ftl" as base />
<#import "../prebuilt/image-list-item.ftl" as imageListItem />
<#import "../ui/elements/table.ftl" as table />
<#import "../ui/elements/button.ftl" as button />
<#import "../prebuilt/image-list-table-item.ftl" as imageListTableItem />
<#import "../prebuilt/image-list-item.ftl" as imageListBoxItem />
<@base.base title="Images" context="home" showTitle=false>
<section class="section">
<div class="container">
<div class="columns is-multiline is-centered">
<div class="column is-8-desktop">
<@input.text id="SearchImages" icon="search" placeholder="Search..." />
</div>
</div>
</div>
</section>
<section class="section is-paddingless-top">
<div class="container">
<div class="columns">
<div class="column is-12">
<#if availableRepositories?has_content && availableRepositories?size &gt; 0>
<@input.dropdown id="RepositorySelection" size="normal" icon="cubes">
<#list availableRepositories as repository>
<option value="${repository.key}"<#if repository.name==selectedRepository.name> selected</#if>>${repository.name}</option>
</#list>
</@input.dropdown>
<#else>
There are currently no available repositories.
</#if>
</div>
</div>
<#-- <div class="columns">-->
<#-- <div class="column is-12 has-text-right">-->
<#-- <#if availableRepositories?has_content && availableRepositories?size &gt; 0>-->
<#-- <@input.dropdown id="RepositorySelection" size="normal" icon="cubes">-->
<#-- <#list availableRepositories as repository>-->
<#-- <option value="${repository.key}"<#if repository.name==selectedRepository.name> selected</#if>>${repository.name}</option>-->
<#-- </#list>-->
<#-- </@input.dropdown>-->
<#-- <#else>-->
<#-- There are currently no available repositories.-->
<#-- </#if>-->
<#-- </div>-->
<#-- </div>-->
<#if selectedRepository?has_content>
<div class="columns is-multiline">
<div class="column is-12">
<h2 class="title is-4 repository-title">
<i class="fas fa-cubes"></i> ${selectedRepository.name}<span class="has-text-primary">.</span>
</h2>
</div>
<div class="column is-12">
<div class="column is-12 has-margin-top">
<nav class="level">
<div class="level-item has-text-centered">
<div>
@ -83,16 +70,69 @@
</div>
</nav>
</div>
<div class="column is-12 has-margin-top">
<h2 class="title is-3 repository-title">
<i class="fas fa-cubes"></i> ${selectedRepository.name}<span class="has-text-primary">.</span>
</h2>
</div>
<div class="column is-12 has-margin-top">
<@input.text id="SearchImages" icon="search" placeholder="Search..." />
</div>
</div>
<div class="columns is-centered is-multiline has-margin-top">
<#if selectedRepository.images?has_content>
<div class="columns has-margin-top">
<div class="column is-12">
<@button.buttons isGrouped=true isRightAligned=true>
<@button.button id="DisplayImageTable" colour="normal-colour" title="Display as table" extraClasses="is-active">
<i class="fas fa-table is-marginless"></i>
</@button.button>
<@button.button id="DisplayImageGrid" colour="normal-colour" title="Display as grid">
<i class="fas fa-th-large is-marginless"></i>
</@button.button>
</@button.buttons>
</div>
</div>
<#if selectedRepository.images?has_content>
<div id="ImageTableHolder" class="columns is-multiline">
<div class="column is-12">
<@table.table id="ImageTable" isFullWidth=true isScrollable=true extraClasses="table--sortable">
<thead>
<tr>
<th>Name</th>
<th></th>
<th>Latest Version</th>
<th>Build Time</th>
<th class="sorter-pullCount">Pulls</th>
<th>Stars</th>
</tr>
</thead>
<tbody>
<#list selectedRepository.images as image>
<@imageListTableItem.imageListItem image=image />
</#list>
</tbody>
</@table.table>
</div>
</div>
<div id="ImageGridHolder" class="columns is-multiline is-hidden">
<#list selectedRepository.images as image>
<@imageListItem.imageListItem image=image />
<@imageListBoxItem.imageListItem image=image />
</#list>
<#else>
</div>
<#else>
<div class="column is-12">
No images
</#if>
</div>
</div>
</#if>
</#if>

View File

@ -0,0 +1,76 @@
<#--
Copyright (c) 2019 LinuxServer.io
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<#import "../prebuilt/base.ftl" as base />
<#import "../ui/layout/section.ftl" as section />
<#import "../ui/layout/container.ftl" as container />
<#import "../ui/elements/box.ftl" as box />
<#import "../ui/elements/table.ftl" as table />
<#import "../ui/elements/tag.ftl" as tag />
<@base.base title="${image.fullName}" context="image">
<@section.section>
<@container.container>
<div class="columns is-multiline">
<div class="column is-12">
<h2 class="title is-3 repository-title">
<i class="fas fa-cube"></i> <span class="has-text-weight-light">${image.repositoryName}/</span>${image.name}<span class="has-text-primary">.</span>
<#if image.deprecated>
<@tag.tag colour="warning" value="Deprecated" />
</#if>
</h2>
<div class="tags is-right">
<@tag.tag colour="light" value='<i class="fas fa-download"></i> ${image.pullCount}' extraAttributes='title="Pulls"' />
<@tag.tag colour="light" value='<i class="fas fa-star"></i> ${image.starCount}' extraAttributes='title="Stars"' />
</div>
</div>
<div class="column is-6-desktop is-12-tablet">
<@box.box extraClasses="is-paddingless is-clipped">
<h2 class="title is-5 has-text-centered has-margin-top">Build Information</h2>
<@table.table isFullWidth=true isNarrow=true isStriped=true>
<tbody>
<@table.halfDisplayRow title="Repository" value='<a href="/?key=${image.repositoryKey}">${image.repositoryName}</a>' />
<@table.halfDisplayRow title="Build Time" value=image.lastUpdatedAsString />
<@table.halfDisplayRow title="Stable" value=image.stable?string("Yes", "No") />
<@table.halfDisplayRow title="Synchronised" value=image.syncEnabled?string("Yes", "No") />
</tbody>
</@table.table>
<h2 class="title is-5 has-text-centered has-margin-top">Tracked Tags</h2>
<@table.table isFullWidth=true isNarrow=true isStriped=true>
<tbody>
<#list image.tagBranches as tagBranch>
<@table.halfDisplayRow title='<i class="fas fa-tag"></i> ${tagBranch.branchName}' value=(tagBranch.latestTag).version!"" />
</#list>
</tbody>
</@table.table>
</@box.box>
</div>
</div>
</@container.container>
</@section.section>
</@base.base>

View File

@ -57,6 +57,7 @@
<#nested>
<script type="text/javascript" src="assets/js/jquery-3.3.1.min.js"></script>
<script type="text/javascript" src="assets/js/jquery.tablesorter.js"></script>
<script type="text/javascript" src="assets/js/app.js"></script>
<script type="text/javascript">
@ -67,6 +68,21 @@
formValidationManager.init();
bulmaManager.init();
jQuery.tablesorter.addParser({
id: "pullCount",
is: function(s) {
return /^[0-9]?[0-9,.]*$/.test(s);
},
format: function(s) {
return jQuery.tablesorter.formatFloat( s.replace(/,/g,'') );
},
type: "numeric"
});
$(function() {
$("table.table--sortable").tablesorter();
});
}, false);
})();

View File

@ -20,40 +20,50 @@
<#macro imageListItem image>
<#if !image.hidden>
<div class="column has-margin-top is-12 is-12-tablet is-8-desktop">
<article class="media">
<figure class="media-left">
<p class="icon has-text-centered">
<i class="fas fa-cube"></i>
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="column is-12-mobile is-6-tablet is-4-desktop" data-image-name="${image.name}">
<div class="box">
<article class="media">
<figure class="media-left">
<p class="icon has-text-centered">
<i class="fas fa-cube"></i>
</p>
</figure>
<div class="media-content">
<div class="content">
<h4 class="title is-6">
<a class="has-text-grey-dark" href="/image?name=${image.fullName}">
<span class="has-text-weight-light">${image.repositoryKey.name}/</span><span class="has-text-weight-500">${image.name}</span>
</a>
</h4>
<h4 class="title is-6 is-marginless">
<a class="has-text-grey-dark" href="/image?name=${image.fullName}">
<span class="has-text-weight-light">${image.repositoryKey.name}/</span><span class="has-text-weight-500">${image.name}</span>
</a>
</h4>
${image.description!""}
<small>
<em>
${image.lastUpdatedAsString}
</em>
</small>
<div class="tags has-margin-top">
<#list image.tagBranches as tagBranch>
<@tag.tag colour="light" value='<i class="fas fa-tag"></i> ${tagBranch.latestTag.version}' extraAttributes='title="Latest Version"' />
</#list>
<div class="tags has-margin-top">
<#list image.tagBranches as tagBranch>
<@tag.tag colour="light" value='<i class="fas fa-tag"></i> ${tagBranch.latestTag.version}' extraAttributes='title="Latest Version"' />
</#list>
<@tag.tag colour="light" value='<i class="fas fa-download"></i> ${image.pullCount}' extraAttributes='title="Pulls"' />
<@tag.tag colour="light" value='<i class="fas fa-star"></i> ${image.starCount}' />
<@tag.tag colour="light" value='<i class="fas fa-download"></i> ${image.pullCount}' extraAttributes='title="Pulls"' />
<@tag.tag colour="light" value='<i class="fas fa-star"></i> ${image.starCount}' />
<#if image.deprecated>
<@tag.tag colour="warning" value='<i class="fas fa-exclamation-circle"></i> Deprecated' />
</#if>
<#if !image.stable>
<@tag.tag colour="danger" value='<i class="fas fa-exclamation-triangle"></i> Unstable!' />
</#if>
</div>
<#if image.deprecated>
<@tag.tag colour="warning" value='<i class="fas fa-exclamation-circle"></i> Deprecated' />
</#if>
</div>
</div>
</div>
</article>
</article>
</div>
</div>
</#if>

View File

@ -0,0 +1,55 @@
<#--
Copyright (c) 2019 LinuxServer.io
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<#import "../ui/elements/tag.ftl" as tag />
<#macro imageListItem image>
<#if !image.hidden>
<tr data-image-name="${image.name}">
<td class="is-vcentered">
<h4 class="title is-6">
<a class="has-text-grey-dark" href="/image?name=${image.fullName}">
<span class="has-text-weight-light">${image.repositoryKey.name}/</span><span class="has-text-weight-500">${image.name}</span>
</a>
</h4>
</td>
<td>
<#if image.deprecated>
<@tag.tag colour="warning" value='<i class="fas fa-exclamation-circle"></i> Deprecated' />
</#if>
<#if !image.stable>
<@tag.tag colour="danger" value='<i class="fas fa-exclamation-triangle"></i> Unstable!' />
</#if>
</td>
<td class="is-vcentered">
<@tag.tag colour="light" value='<i class="fas fa-tag"></i> ${image.latestTag.version}' extraAttributes='title="Latest Version"' />
</td>
<td class="is-vcentered">
${image.lastUpdatedAsString!""}
</td>
<td class="is-vcentered">
${image.pullCount}
</td>
<td class="is-vcentered">
${image.starCount}
</td>
</tr>
</#if>
</#macro>

View File

@ -1,54 +1,54 @@
<#--
Resource: https://bulma.io/documentation/elements/button/
The classic button, in different colors, sizes, and states
id:String - The unique identifier for this element
type:enum(button, submit) - Sets the type of button. If "submit", it will interact with its parent form.
title:String - A description of the button to give further context.
extraClasses:String - Any additional classes that this element should have
extraAttributes:String - Any additional attributes that this element should have (e.g. disabled, data-*)
colour:enum(primary, warning, info, danger, success, light, dark) - Sets the main colour of the button. Default: primary
size:enum(small, normal, medium, large) - Sets the size of the button.
isWide:boolean - If set to true, will fill available horizontal space.
isOutlined:boolean - If set to true, will set colour on button outline rather than background.
isInverted:boolean - If set to true, will set the text colour rather than background.
isRounded:boolean - If set to true, will fully round the edges of the button.
-->
<#macro button id="" type="button" title="" extraClasses="" extraAttributes="" colour="primary" size="normal" modal="" isDisabled=false isWide=false isOutlined=false isInverted=false isRounded=false>
<button type="${type}"<#if id?has_content> id="${id}"</#if><#if title?has_content> title="${title}"</#if> class="button is-${size} is-${colour}<#if isWide> is-fullwidth</#if><#if isOutlined> is-outlined</#if><#if isInverted> is-inverted</#if><#if isRounded> is-rounded</#if><#if extraClasses?has_content> ${extraClasses}</#if><#if modal?has_content> is-modal-trigger</#if>"<#if modal?has_content> data-modal="${modal}"</#if><#if extraAttributes?has_content> ${extraAttributes}</#if><#if isDisabled> disabled</#if>>
<#nested />
</button>
</#macro>
<#--
A convenience macro to generate a submit button.
-->
<#macro submit id="" extraClasses="" title="" extraAttributes="" colour="primary" size="normal" isWide=false isOutlined=false isInverted=false isRounded=false>
<@button id=id type="submit" title=title extraClasses=extraClasses extraAttributes=extraAttributes colour=colour size=size isWide=isWide isOutlined=isOutlined isInverted=isInverted isRounded=isRounded>
<#nested />
</@button>
</#macro>
<#--
Generates an anchor link with the same styling as a button.
-->
<#macro link id="" link="" title="" extraClasses="" extraAttributes="" colour="primary" size="normal" modal="" isWide=false isOutlined=false isInverted=false isRounded=false>
<a<#if link?has_content> href="${link}"</#if><#if title?has_content> title="${title}"</#if> class="button is-${size} is-${colour}<#if isWide> is-fullwidth</#if><#if isOutlined> is-outlined</#if><#if isInverted> is-inverted</#if><#if isRounded> is-rounded</#if><#if extraClasses?has_content> ${extraClasses}</#if><#if modal?has_content> is-modal-trigger</#if>"<#if modal?has_content> data-modal="${modal}"</#if><#if extraAttributes?has_content> ${extraAttributes}</#if>>
<#nested />
</a>
</#macro>
<#macro buttons isGrouped=false size="normal">
<div class="buttons<#if isGrouped> has-addons</#if> are-${size}">
<#nested />
</div>
<#--
Resource: https://bulma.io/documentation/elements/button/
The classic button, in different colors, sizes, and states
id:String - The unique identifier for this element
type:enum(button, submit) - Sets the type of button. If "submit", it will interact with its parent form.
title:String - A description of the button to give further context.
extraClasses:String - Any additional classes that this element should have
extraAttributes:String - Any additional attributes that this element should have (e.g. disabled, data-*)
colour:enum(primary, warning, info, danger, success, light, dark) - Sets the main colour of the button. Default: primary
size:enum(small, normal, medium, large) - Sets the size of the button.
isWide:boolean - If set to true, will fill available horizontal space.
isOutlined:boolean - If set to true, will set colour on button outline rather than background.
isInverted:boolean - If set to true, will set the text colour rather than background.
isRounded:boolean - If set to true, will fully round the edges of the button.
-->
<#macro button id="" type="button" title="" extraClasses="" extraAttributes="" colour="primary" size="normal" modal="" isDisabled=false isWide=false isOutlined=false isInverted=false isRounded=false>
<button type="${type}"<#if id?has_content> id="${id}"</#if><#if title?has_content> title="${title}"</#if> class="button is-${size} is-${colour}<#if isWide> is-fullwidth</#if><#if isOutlined> is-outlined</#if><#if isInverted> is-inverted</#if><#if isRounded> is-rounded</#if><#if extraClasses?has_content> ${extraClasses}</#if><#if modal?has_content> is-modal-trigger</#if>"<#if modal?has_content> data-modal="${modal}"</#if><#if extraAttributes?has_content> ${extraAttributes}</#if><#if isDisabled> disabled</#if>>
<#nested />
</button>
</#macro>
<#--
A convenience macro to generate a submit button.
-->
<#macro submit id="" extraClasses="" title="" extraAttributes="" colour="primary" size="normal" isWide=false isOutlined=false isInverted=false isRounded=false>
<@button id=id type="submit" title=title extraClasses=extraClasses extraAttributes=extraAttributes colour=colour size=size isWide=isWide isOutlined=isOutlined isInverted=isInverted isRounded=isRounded>
<#nested />
</@button>
</#macro>
<#--
Generates an anchor link with the same styling as a button.
-->
<#macro link id="" link="" title="" extraClasses="" extraAttributes="" colour="primary" size="normal" modal="" isWide=false isOutlined=false isInverted=false isRounded=false>
<a<#if link?has_content> href="${link}"</#if><#if title?has_content> title="${title}"</#if> class="button is-${size} is-${colour}<#if isWide> is-fullwidth</#if><#if isOutlined> is-outlined</#if><#if isInverted> is-inverted</#if><#if isRounded> is-rounded</#if><#if extraClasses?has_content> ${extraClasses}</#if><#if modal?has_content> is-modal-trigger</#if>"<#if modal?has_content> data-modal="${modal}"</#if><#if extraAttributes?has_content> ${extraAttributes}</#if>>
<#nested />
</a>
</#macro>
<#macro buttons isGrouped=false size="normal" isRightAligned=false>
<div class="buttons<#if isGrouped> has-addons</#if><#if isRightAligned> is-right</#if> are-${size}">
<#nested />
</div>
</#macro>