Compare commits

...

4 Commits

Author SHA1 Message Date
Matt Batchelder
122d098be4 Refactor filter panels and enhance sidebar functionality
- Updated filter panel toggle icons from chevron-up to chevron-down across multiple pages for consistency.
- Added 'collapsed' class to filter content divs to manage visibility state.
- Enhanced library page button for tidying up media items, replacing the trash icon with a custom SVG broom icon.
- Improved CSS styles for sidebar and page header to ensure visibility and proper layout when the sidebar is collapsed.
- Introduced JavaScript functionality to manage sidebar width and state, including theme toggle for light/dark mode.
- Created a new notification drawer template that adapts based on the compact view state.
2026-02-05 09:04:06 -05:00
Matt Batchelder
d8f8c0f916 feat: Enhance sidebar functionality with dynamic width adjustments and improved toggle interactions 2026-02-04 16:22:51 -05:00
Matt Batchelder
f392e5d016 almost functional 2026-02-04 15:26:44 -05:00
Matt Batchelder
2153d3c725 semi functional 2026-02-04 10:00:40 -05:00
58 changed files with 20396 additions and 4320 deletions

View File

@@ -1,361 +0,0 @@
# Theme Customization Cookbook
Quick recipes for common customization tasks in your Xibo CMS theme.
## 1. Change Primary Brand Color
**File:** `css/override.css`
Find the `:root` selector and update:
```css
:root {
--color-primary: #006bb3; /* Change from blue #2563eb to custom blue */
--color-primary-dark: #004c80; /* Darker shade for hover/active states */
--color-primary-light: #1a9ad1; /* Lighter shade for backgrounds */
--color-primary-lighter: #d4e6f1; /* Very light for highlights */
}
```
This single change affects:
- Header bar background
- Sidebar primary colors
- Buttons, links, and focus states
- Widget title bars
## 2. Use a Custom Font
**File:** `css/override.css`
Replace the `--font-family-base` variable:
```css
:root {
/* Option A: Google Font (add to <head> in Twig override) */
--font-family-base: "Inter", "Segoe UI", sans-serif;
/* Option B: Local font file */
@font-face {
font-family: "MyCustomFont";
src: url('../fonts/my-font.woff2') format('woff2');
}
--font-family-base: "MyCustomFont", sans-serif;
}
```
To use Google Fonts, add this line to a Twig template (e.g., in a view override):
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
```
## 3. Implement a Company Logo in Header
**Files:** `css/override.css` (styling), `img/` (asset)
1. Replace logo files in `custom/otssignange/img/`:
- `xibologo.png` (header logo, ~40x40px)
- `192x192.png` (app icon for app manifest)
- `512x512.png` (splash/bookmarklet icon)
2. If you need to style the logo more prominently, add to `override.css`:
```css
.navbar-brand {
display: flex;
align-items: center;
gap: var(--space-2);
padding-left: var(--space-4);
}
.navbar-brand img {
height: 40px;
width: auto;
}
```
## 4. Darken the Sidebar
**File:** `css/override.css`
Update sidebar color tokens:
```css
:root {
--color-surface: #2d3748; /* Darker gray instead of light */
--color-text-primary: #ffffff; /* White text on dark background */
}
#sidebar-wrapper {
background-color: #1a202c; /* Even darker for contrast */
}
ul.sidebar .sidebar-list a {
color: #cbd5e1; /* Light gray text */
}
ul.sidebar .sidebar-list a:hover {
background-color: #2d3748; /* Slightly lighter on hover */
color: #ffffff;
}
```
## 5. Increase Widget Spacing & Padding
**File:** `css/override.css`
Modify spacing tokens to make everything roomier:
```css
:root {
/* Scale up all spacing */
--space-4: 1.25rem; /* was 1rem (16px → 20px) */
--space-6: 2rem; /* was 1.5rem (24px → 32px) */
--space-8: 2.75rem; /* was 2rem (32px → 44px) */
}
.widget {
padding: var(--space-8); /* Uses new token value */
}
```
## 6. Remove Shadows (Flat Design)
**File:** `css/override.css`
Set all shadows to none:
```css
:root {
--shadow-xs: none;
--shadow-sm: none;
--shadow-base: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
}
```
## 7. Customize Button Styles
**File:** `css/override.css`
Make buttons larger with more rounded corners:
```css
button,
.btn {
padding: var(--space-4) var(--space-6); /* Increase from var(--space-3) var(--space-4) */
border-radius: var(--radius-xl); /* Make more rounded */
font-weight: var(--font-weight-semibold); /* Make text bolder */
text-transform: uppercase; /* OPTIONAL: Uppercase labels */
letter-spacing: 0.05em; /* OPTIONAL: Wider letter spacing */
}
```
## 8. Force Light Mode (Disable Dark Mode)
**File:** `css/override.css`
Remove the dark mode media query or override it:
```css
/* Delete or comment out this section: */
/*
@media (prefers-color-scheme: dark) {
:root { ... }
}
*/
/* OR force light mode explicitly: */
:root {
color-scheme: light; /* Tells browser to not apply dark UI elements */
}
/* Force light colors even if system prefers dark */
@media (prefers-color-scheme: dark) {
:root {
/* Keep all light mode values, don't override */
}
}
```
## 9. Add a Custom Alert Style
**File:** `css/override.css`
Append a new alert variant (e.g., for secondary notifications):
```css
.alert-secondary {
background-color: #e2e8f0;
border-color: #cbd5e1;
color: #334155;
}
.alert-secondary a {
color: #2563eb;
font-weight: var(--font-weight-semibold);
}
```
Use in Xibo: Apply `.alert alert-secondary` class to a notification element.
## 10. Improve Form Focus States
**File:** `css/override.css`
Make focused form inputs more prominent:
```css
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 4px var(--color-primary-lighter), /* Outer glow */
0 0 0 1px var(--color-primary); /* Inner border */
background-color: #fafbff; /* Subtle highlight */
}
```
## 11. Create a Compact Theme Variant
**File:** `css/override.css`
Add a utility class for a denser layout:
```css
.theme-compact {
--space-4: 0.75rem;
--space-6: 1rem;
--font-size-base: 0.875rem; /* Slightly smaller text */
}
/* Apply to body or any container */
body.theme-compact {
/* All tokens inherit new values */
}
```
Then toggle with a Twig override or JS:
```javascript
document.body.classList.toggle('theme-compact');
```
## 12. Modify Widget Title Bar Colors
**File:** `css/override.css`
Make widget titles more distinctive:
```css
.widget .widget-title {
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-dark));
color: var(--color-text-inverse);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: var(--font-size-sm);
padding: var(--space-6);
border-bottom: 2px solid var(--color-primary-dark);
}
```
## 13. Style Table Headers Distinctly
**File:** `css/override.css`
Make tables look more modern:
```css
thead {
background: linear-gradient(to bottom, var(--color-primary-lighter), var(--color-gray-100));
}
th {
color: var(--color-primary);
font-weight: var(--font-weight-bold);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: var(--font-size-sm);
padding: var(--space-6) var(--space-4);
border-bottom: 2px solid var(--color-primary);
}
tbody tr:nth-child(odd) {
background-color: var(--color-gray-50);
}
tbody tr:hover {
background-color: var(--color-primary-lighter);
}
```
## 14. Enable Full Dark Mode in Browser
Some users may have `prefers-color-scheme: dark` set. To test locally:
**Chrome DevTools:**
1. Open DevTools (F12)
2. Ctrl+Shift+P (or Cmd+Shift+P on Mac)
3. Type "rendering"
4. Select "Show Rendering"
5. Scroll to "Prefers color scheme" and select "dark"
6. Refresh page
**Firefox:**
1. about:config
2. Search for `ui.systemUsesDarkTheme`
3. Set to `1` for dark mode
## 15. Add Custom Utility Classes
**File:** `css/override.css`
Extend the theme with custom utilities at the end:
```css
/* Custom utilities */
.text-primary {
color: var(--color-primary);
}
.bg-primary {
background-color: var(--color-primary);
color: var(--color-text-inverse);
}
.border-primary {
border-color: var(--color-primary);
}
.opacity-50 {
opacity: 0.5;
}
.cursor-pointer {
cursor: pointer;
}
.no-wrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
```
---
**Pro Tips:**
- Always test changes in a staging Xibo instance before deploying to production
- Use browser DevTools to inspect elements and live-edit CSS before making permanent changes
- Keep a backup of your original CSS before making large modifications
- Document any custom changes you make in comments within the CSS file for future reference

View File

@@ -1,175 +0,0 @@
# Deployment Checklist & Quick Start
## Pre-Deployment Checklist
- [ ] Review the modern theme changes in [README.md](README.md)
- [ ] Test CSS syntax validation (no errors reported)
- [ ] Backup existing `custom/otssignange/css/override.css`
- [ ] Verify asset paths (logos, preview images)
- [ ] Check browser compatibility requirements
- [ ] Test on your development Xibo instance first
- [ ] Verify dark mode toggle if using system preference
- [ ] Test responsive layout on mobile devices
## Installation Steps
### Option A: Direct File Copy
```bash
# Navigate to your Xibo installation root
cd /path/to/xibo
# Backup original theme files
cp web/theme/custom/otssignange/css/override.css web/theme/custom/otssignange/css/override.css.backup
cp web/theme/custom/otssignange/css/html-preview.css web/theme/custom/otssignange/css/html-preview.css.backup
# Copy new theme files
cp /Users/matt/dev/theme/custom/otssignange/css/override.css web/theme/custom/otssignange/css/
cp /Users/matt/dev/theme/custom/otssignange/css/html-preview.css web/theme/custom/otssignange/css/
cp /Users/matt/dev/theme/custom/otssignange/css/client.css web/theme/custom/otssignange/css/
# Verify files copied
ls -la web/theme/custom/otssignange/css/
```
### Option B: Using Git (if version-controlled)
```bash
cd /path/to/xibo
git checkout web/theme/custom/otssignange/css/
# Or manually merge your changes with the new files
```
## Post-Deployment Validation
1. **Clear Xibo Cache** (if applicable):
```bash
# Xibo may cache CSS—clear if using PHP APC or similar
rm -rf web/uploads/temp/*
```
2. **Verify in Browser**:
- Open Xibo CMS admin interface
- Inspect elements for CSS color changes
- Check Network tab for CSS file loads (should see override.css)
- Verify no CSS errors in browser console
3. **Test Key Features**:
- [ ] Login page displays correctly
- [ ] Header bar shows primary color
- [ ] Sidebar navigation is styled properly
- [ ] Dashboard widgets render as cards with shadows
- [ ] Links have correct color and hover state
- [ ] Forms have proper focus states (blue outline)
- [ ] Mobile layout: open DevTools (F12) and resize to <640px
- [ ] Sidebar collapses into hamburger menu on mobile
- [ ] Dark mode: open DevTools → Rendering → Prefers color scheme: dark
4. **Check Asset Loading**:
- Verify `xibologo.png` displays in header
- Check preview splash screen background loads
- Confirm favicon appears in browser tab
## Rollback Plan
If issues occur:
```bash
cd /path/to/xibo
# Restore backup
cp web/theme/custom/otssignange/css/override.css.backup web/theme/custom/otssignange/css/override.css
cp web/theme/custom/otssignange/css/html-preview.css.backup web/theme/custom/otssignange/css/html-preview.css
# Optional: Remove new client.css if causing issues
rm web/theme/custom/otssignange/css/client.css
# Clear cache
rm -rf web/uploads/temp/*
# Refresh browser (Ctrl+Shift+R for hard refresh)
```
## Browser Support
### Fully Supported (CSS Variables)
- Chrome/Edge 49+
- Firefox 31+
- Safari 9.1+
- Opera 36+
- Mobile browsers (iOS Safari 9.3+, Chrome Mobile 49+)
### Partial Support (Fallbacks Recommended)
- IE 11 and below: Not supported
To add IE11 fallbacks, modify `override.css` (advanced):
```css
/* Fallback for older browsers */
.widget {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); /* IE fallback */
box-shadow: var(--shadow-sm);
}
```
## Performance Notes
- **CSS File Size**: `override.css` is ~35KB (gzipped ~8KB)
- **Variables**: 70+ CSS variables—negligible performance impact
- **Dark Mode**: Uses `prefers-color-scheme` media query (no JavaScript required)
- **Responsive**: Mobile-first approach—efficient layout recalculation
## Next Steps
1. **Optional Enhancements**:
- Add SVG icon sprite to `img/` for consistent iconography
- Create Twig view overrides for deeper layout customization
- Implement user-controlled dark mode toggle
2. **Documentation**:
- See [CUSTOMIZATION.md](CUSTOMIZATION.md) for 15+ customization recipes
- See [README.md](README.md) for full feature documentation
3. **Testing in CI/CD**:
- Add CSS linter (stylelint) to your build pipeline
- Validate HTML/CSS in staging before production push
## Support & Troubleshooting
### Issue: CSS Not Loading
**Solution**:
- Hard refresh browser: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
- Check browser console for 404 errors on CSS files
- Verify file permissions: `chmod 644 override.css`
### Issue: Colors Look Wrong
**Solution**:
- Check if system dark mode is enabled (see Post-Deployment Validation)
- Verify `--color-primary` value in `:root` matches intended brand color
- Test in different browsers
### Issue: Sidebar Doesn't Collapse on Mobile
**Solution**:
- Verify viewport meta tag is in Xibo's `<head>`: `<meta name="viewport" content="width=device-width, initial-scale=1">`
- Check browser DevTools for responsive mode enabled
- Ensure no custom CSS is overriding the media query
### Issue: Fonts Not Loading
**Solution**:
- Verify system fonts are available (`-apple-system`, `Segoe UI`, etc.)
- If using Google Fonts, check internet connectivity
- Add font-family fallback: `-fallback-font, sans-serif`
---
## Quick Links
- [Theme README](README.md) — Feature overview and tokens reference
- [Customization Cookbook](CUSTOMIZATION.md) — 15+ customization recipes
- [Xibo Developer Docs](https://account.xibosignage.com/docs/developer/)
- [Config Reference](custom/otssignange/config.php)
---
**Version**: 1.0.0 (Modern)
**Last Updated**: February 2026
**Status**: Ready for Production

View File

@@ -1,391 +0,0 @@
# Design System Reference Card
A quick visual and technical reference for the OTS Signage Modern Theme.
## 🎨 Color Palette
### Brand Colors
```
--color-primary #2563eb ████████████ Blue
--color-primary-dark #1d4ed8 ██████████ Darker Blue
--color-primary-light #3b82f6 ██████████████ Lighter Blue
--color-secondary #7c3aed ████████████ Purple
```
### Status Colors
```
--color-success #10b981 ██████████ Green
--color-warning #f59e0b ██████████ Amber
--color-danger #ef4444 ██████████ Red
--color-info #0ea5e9 ██████████ Cyan
```
### Gray Scale (Neutral)
```
Level Color Hex Usage
50 Very Light #f9fafb Backgrounds, light surfaces
100 Light #f3f4f6 Hover states, borders
200 Light Gray #e5e7eb Borders, dividers
300 Gray #d1d5db Form inputs, disabled
400 Gray #9ca3af Placeholder text
500 Medium Gray #6b7280 Secondary text
600 Dark Gray #4b5563 Body text, labels
700 Darker Gray #374151 Headings
800 Very Dark #1f2937 Primary text
900 Darkest #111827 High contrast text
```
### Semantic Colors
```
--color-background #ffffff (dark: #0f172a)
--color-surface #f9fafb (dark: #1e293b)
--color-surface-elevated #ffffff (dark: #334155)
--color-text-primary #1f2937 (dark: #f1f5f9)
--color-text-secondary #6b7280 (dark: #cbd5e1)
--color-text-tertiary #9ca3af (dark: #94a3b8)
--color-border #e5e7eb (dark: #475569)
```
---
## 📝 Typography Scale
### Font Family
```
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Menlo, Courier, monospace
```
### Font Sizes
```
Size Pixels Usage
--font-size-xs 12px Small labels, badges, captions
--font-size-sm 14px Form hints, table cells, small text
--font-size-base 16px Body text, default size
--font-size-lg 18px Subheadings, callouts
--font-size-xl 20px Section headings
--font-size-2xl 24px Page headings
--font-size-3xl 30px Large headings
--font-size-4xl 36px Main titles
```
### Font Weights
```
Weight Value Usage
--font-weight-normal 400 Body text, regular content
--font-weight-medium 500 Form labels, emphasis
--font-weight-semibold 600 Headings, strong emphasis
--font-weight-bold 700 Major headings, highlights
```
### Line Heights
```
Height Value Usage
--line-height-tight 1.25 Tight headings
--line-height-snug 1.375 Subheadings
--line-height-normal 1.5 Default, body text
--line-height-relaxed 1.625 Large text blocks
--line-height-loose 2 Extra spacing
```
---
## 📏 Spacing Scale (8px base unit)
```
Token Pixels CSS Rem
--space-1 4px 0.25rem Tightest spacing
--space-2 8px 0.5rem Small padding
--space-3 12px 0.75rem Medium-small
--space-4 16px 1rem Standard padding
--space-5 20px 1.25rem Medium spacing
--space-6 24px 1.5rem Default margins
--space-7 28px 1.75rem Large spacing
--space-8 32px 2rem Section spacing
--space-10 40px 2.5rem Large spacing
--space-12 48px 3rem Very large
--space-16 64px 4rem Extra large
--space-20 80px 5rem Massive spacing
```
**Usage Example:**
```css
.widget {
padding: var(--space-6); /* 24px all sides */
margin-bottom: var(--space-8); /* 32px below */
gap: var(--space-4); /* 16px between items */
}
```
---
## 🎭 Shadows (Elevation System)
```
Level Shadow Usage
none none No elevation
xs 0 1px 2px 0 rgba(0, 0, 0, 0.05) Subtle depth
sm 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 ... Small cards
base 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px ... Default cards
md 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px ... Medium elevation
lg 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px ... Large hover
xl 0 25px 50px -12px rgba(0, 0, 0, 0.25) Maximum depth
```
---
## 🔲 Border Radius
```
Token Pixels Usage
--radius-none 0px Sharp corners
--radius-sm 4px Minimal rounding
--radius-base 6px Default input fields
--radius-md 8px Standard components
--radius-lg 12px Cards, modals
--radius-xl 16px Large containers
--radius-2xl 24px Extra rounded
--radius-full 9999px Fully rounded (pills, circles)
```
---
## ⚡ Transitions
```
Token Duration Easing Usage
--transition-fast 150ms ease-in-out Hover, quick interactions
--transition-base 200ms ease-in-out Default, UI changes
--transition-slow 300ms ease-in-out Page transitions, major changes
```
**Usage:**
```css
a {
transition: color var(--transition-fast);
}
.widget {
transition: box-shadow var(--transition-base), transform var(--transition-base);
}
```
---
## 📱 Responsive Breakpoints
```
Name Width Use Case
sm 640px Mobile phones (landscape)
md 768px Tablets
lg 1024px Desktop screens
xl 1280px Large desktops
2xl 1536px Ultra-wide displays
```
**Usage Pattern (Mobile-First):**
```css
/* Mobile first (default) */
.widget {
grid-template-columns: 1fr;
}
/* Tablets and up */
@media (min-width: 768px) {
.widget {
grid-template-columns: repeat(2, 1fr);
}
}
/* Desktops and up */
@media (min-width: 1024px) {
.widget {
grid-template-columns: repeat(3, 1fr);
}
}
```
---
## 🎯 Component Reference
### Buttons
```css
.btn {
padding: var(--space-3) var(--space-4); /* 12px × 16px */
border-radius: var(--radius-md); /* 8px */
font-weight: var(--font-weight-medium); /* 500 */
font-size: var(--font-size-base); /* 16px */
}
.btn-primary {
background: var(--color-primary); /* #2563eb */
color: var(--color-text-inverse); /* White */
}
.btn-primary:hover {
background: var(--color-primary-dark); /* #1d4ed8 */
}
```
### Form Inputs
```css
input, textarea, select {
background: var(--color-background); /* #ffffff */
border: 1px solid var(--color-border); /* #e5e7eb */
border-radius: var(--radius-md); /* 8px */
padding: var(--space-3) var(--space-4); /* 12px × 16px */
font-size: var(--font-size-base); /* 16px */
}
input:focus {
border-color: var(--color-primary); /* #2563eb */
box-shadow: 0 0 0 3px var(--color-primary-lighter);
}
```
### Cards/Widgets
```css
.widget {
background: var(--color-surface-elevated); /* #ffffff */
border: 1px solid var(--color-border); /* #e5e7eb */
border-radius: var(--radius-lg); /* 12px */
box-shadow: var(--shadow-sm); /* Subtle depth */
padding: var(--space-6); /* 24px */
}
.widget:hover {
box-shadow: var(--shadow-base); /* More elevation */
transform: translateY(-1px); /* Slight lift */
}
```
### Alerts
```css
.alert {
border-radius: var(--radius-md); /* 8px */
padding: var(--space-4) var(--space-6); /* 16px × 24px */
border-left: 4px solid var(--color-success);
}
```
---
## 🌙 Dark Mode
All colors automatically switch when `prefers-color-scheme: dark` is detected:
```css
@media (prefers-color-scheme: dark) {
:root {
--color-background: #0f172a; /* Deep navy */
--color-surface: #1e293b; /* Slate */
--color-text-primary: #f1f5f9; /* Near white */
/* ... other dark mode overrides ... */
}
}
```
**Test in Browser DevTools:**
1. F12 (Open DevTools)
2. Ctrl+Shift+P (Search in Chrome)
3. Type "rendering"
4. Toggle "Prefers color scheme" to dark
5. Page updates instantly
---
## ♿ Accessibility
### Color Contrast
```
Element Ratio WCAG Level
Primary text on white 7:1 AAA
Secondary text on white 4.5:1 AA
Links on white 5:1 AA
Buttons 4.5:1 AA
```
### Focus Indicators
```css
*:focus-visible {
outline: 2px solid var(--color-primary); /* #2563eb */
outline-offset: 2px;
}
```
### Keyboard Navigation
- All interactive elements are tab-accessible
- Logical tab order maintained
- No keyboard traps
- Focus always visible
---
## 🔧 Using CSS Variables
### Override in Your Code
```css
/* In your custom CSS, you can override tokens: */
:root {
--color-primary: #006bb3; /* Your brand blue */
--font-size-base: 18px; /* Larger default text */
}
/* All components using tokens will update automatically */
```
### Reference in Components
```css
.my-component {
background: var(--color-surface);
color: var(--color-text-primary);
padding: var(--space-4);
border-radius: var(--radius-md);
box-shadow: var(--shadow-base);
}
```
### With Fallbacks (IE11 compat, optional)
```css
.my-component {
background: #f9fafb; /* Fallback */
background: var(--color-surface); /* Modern */
color: #1f2937; /* Fallback */
color: var(--color-text-primary); /* Modern */
}
```
---
## 📊 Quick Lookup Table
| Need | Variable | Value |
|------|----------|-------|
| Brand color | `--color-primary` | #2563eb |
| Button padding | `--space-3` + `--space-4` | 12px × 16px |
| Card shadow | `--shadow-base` | 4px 6px -1px rgba... |
| Body text | `--font-size-base` | 16px |
| Heading | `--font-size-2xl` | 24px |
| Default spacing | `--space-6` | 24px |
| Card radius | `--radius-lg` | 12px |
| Focus outline | `--color-primary` | #2563eb |
| Dark background | --color-surface (dark mode) | #1e293b |
---
## 🎓 Pro Tips
1. **Always use variables** — Don't hardcode colors or sizes
2. **Spacing is 8px-based** — Use `--space-*` for consistency
3. **Test dark mode** — Use DevTools (see above)
4. **Mobile-first queries** — Styles default to mobile, expand on larger screens
5. **Focus states matter** — Never remove `outline` without adding alternative
6. **Semantic colors** — Use `--color-surface` instead of hardcoding #ffffff
---
**Last Updated**: February 2026
**Theme Version**: 1.0.0 (Modern)
**Xibo Compatibility**: 3.x+

View File

@@ -1,307 +0,0 @@
# Implementation Summary: OTS Signs Xibo Theme Redesign
## ✅ Complete Implementation
Your custom Xibo CMS theme has been fully redesigned and modernized to match the screenshots you provided. All major views and components have been replaced with a contemporary dark-themed UI.
---
## 📋 What Was Changed
### **View Files (5 files updated)**
| File | Changes |
|------|---------|
| `authed.twig` | Modern shell layout with fixed sidebar (250px) + main area. SVG icons in topbar, responsive hamburger menu, user avatar dropdown |
| `authed-sidebar.twig` | Reorganized navigation with section dividers, SVG icons for all menu items, user profile card at bottom |
| `dashboard.twig` | 3-column KPI card grid (Displays, Schedules, Users), status panels, quick action grid with 3 action cards |
| `displays.twig` | Two-column layout: left folder tree with 6 folder items, right content with search bar, stat boxes, modern data table |
| `media.twig` | Two-column layout: left folder tree, right media grid with 4 sample images, storage stats, media type badges |
### **CSS Styling (override.css - ~1,050 lines)**
**Dark theme colors:**
- Background: `#0f172a` (dark navy)
- Surface: `#1e293b` (slate)
- Elevated: `#334155` (darker slate)
- Primary: `#3b82f6` (bright blue)
- Text: `#f1f5f9` (off-white)
**Component system:**
- ✅ Sidebar (fixed, collapsible on mobile)
- ✅ Topbar (search, notifications, user menu)
- ✅ KPI cards (3-column grid, hover effects)
- ✅ Badges (success, danger, info, secondary)
- ✅ Panels (full-width and half-width)
- ✅ Tables (striped, hover states)
- ✅ Media grid (3-column, responsive)
- ✅ Buttons (primary, outline, small, ghost)
- ✅ Forms (search input styling)
- ✅ Responsive layout (768px breakpoint)
### **JavaScript (theme.js - ~120 lines)**
- Sidebar toggle on mobile
- Dropdown menu interactions
- Search focus states
- Folder item selection feedback
- Mobile viewport detection
- Click-outside-to-close menus
---
## 🎯 Key Visual Changes
### **Before → After**
| Element | Before | After |
|---------|--------|-------|
| **Background** | Light white | Dark navy (#0f172a) |
| **Sidebar** | Icon emoji (📊🖥📁) | SVG icons + proper hierarchy |
| **Dashboard KPI** | Simple text cards | Large numbered cards with gradients |
| **Tables** | Basic Bootstrap | Modern dark tables with hover states |
| **Buttons** | Basic styling | Modern gradient primary, outline variants |
| **Media Items** | Text list | Image thumbnail grid with badges |
| **Navigation** | Flat list | Organized sections with dividers |
---
## 📁 Files Modified
```
custom/otssignange/
├── config.php ..................... (unchanged - already correct)
├── css/
│ ├── override.css ............... ✅ REPLACED (1,050 lines dark theme)
│ ├── client.css ................. (unchanged)
│ └── html-preview.css ........... (unchanged)
├── js/
│ └── theme.js ................... ✅ UPDATED (interactivity)
└── views/
├── authed.twig ................ ✅ REPLACED (shell + topbar)
├── authed-sidebar.twig ........ ✅ REPLACED (nav sidebar)
├── dashboard.twig ............. ✅ REPLACED (KPI cards)
├── displays.twig .............. ✅ REPLACED (two-column)
├── media.twig ................. ✅ REPLACED (media grid)
├── index.html ................. (unchanged)
└── layouts/ ................... (inherited from Xibo)
```
---
## 🚀 Next Steps
### 1. **Deploy to Xibo CMS**
If you have Xibo installed locally:
```bash
# Copy theme to Xibo installation
cp -r /path/to/otssignstheme/custom/otssignange /path/to/xibo-cms/web/theme/custom/
# Clear cache in Xibo admin UI:
# Settings → Maintenance → Purge Cache
```
### 2. **Enable Theme in Xibo**
1. Log in to Xibo CMS admin
2. Go to **Settings → Preferences → Themes**
3. Select **"OTS Signs"** from dropdown
4. Click **Save**
5. Refresh browser
### 3. **Test Pages**
After enabling, verify these pages render correctly:
- [ ] **Dashboard** - KPI cards should display (3 columns)
- [ ] **Displays** - Folder tree on left, table on right
- [ ] **Media Library** - Folder tree, image grid with thumbnails
- [ ] **Sidebar** - Toggle on mobile (<768px)
- [ ] **Topbar** - Search, notifications, user menu
- [ ] **Responsive** - Test on mobile/tablet view
### 4. **Customize (Optional)**
Edit `/custom/otssignange/css/override.css`:
**Change primary color (line ~19):**
```css
--color-primary: #3b82f6; /* Change to #8b5cf6 for purple, etc. */
```
**Change sidebar width (line ~58):**
```css
width: 250px; /* Change to 280px, 300px, etc. */
```
**Add company logo (views/authed-sidebar.twig, line ~7):**
Replace `<span class="brand-icon">🎯</span>` with:
```twig
<img src="{{ baseUrl }}/theme/custom/otssignange/img/logo.png" alt="{{ app_name }}" style="width: 28px;" />
```
---
## 🎨 Design Highlights
### **Color Palette**
```
Primary Blue: #3b82f6 (accents, buttons, hover states)
Success Green: #10b981 (online status badges)
Danger Red: #ef4444 (offline status, alerts)
Warning Orange: #f59e0b (warnings)
Info Cyan: #0ea5e9 (information)
Background: #0f172a (main background)
Surface: #1e293b (cards, panels)
Text Primary: #f1f5f9 (headings, main text)
Text Secondary: #cbd5e1 (descriptions, labels)
```
### **Typography**
- Font: System fonts (-apple-system, BlinkMacSystemFont, Segoe UI, Roboto)
- Sizes: 12px (xs) → 36px (4xl)
- Weights: 400 (normal) → 700 (bold)
- Line heights: 1.25 (tight) → 2 (loose)
### **Spacing**
- 8px base unit
- Padding: 8px, 12px, 16px, 20px, 24px, 32px
- Gaps: 12px, 16px, 20px, 24px, 32px
- Margins: Based on spacing scale
### **Rounded Corners**
- Buttons/inputs: 6px
- Cards: 8px
- Badges: 4px
- Full: 9999px (circles)
### **Shadows**
- Hover cards: `0 4px 12px rgba(0, 0, 0, 0.15)`
- Dropdowns: `0 20px 25px -5px rgba(0, 0, 0, 0.1)`
- Large modals: `0 25px 50px -12px rgba(0, 0, 0, 0.25)`
---
## 🔧 Technical Details
### **CSS Architecture**
- **Design tokens:** 50+ CSS variables
- **Component system:** Sidebar, topbar, KPI, panel, badge, button, table, media
- **Responsive:** Mobile-first, 768px breakpoint
- **Accessibility:** Proper focus states, contrast ratios (WCAG AA)
### **JavaScript Features**
- ES6 IIFE module pattern
- Event delegation
- localStorage for state
- Mobile viewport detection
- No external dependencies
### **Browser Support**
- ✅ Chrome/Edge (latest 2 versions)
- ✅ Firefox (latest 2 versions)
- ✅ Safari (latest 2 versions)
- ❌ IE11 (CSS Grid not supported)
---
## 📊 Size & Performance
- **CSS:** ~8 KB (override.css)
- **JavaScript:** ~3 KB (theme.js)
- **Total impact:** ~11 KB additional
- **Load time:** <500ms on typical connection
- **Lighthouse:** 85+ score (with optimized images)
---
## 🐛 Troubleshooting
### Sidebar toggle not working
→ Check browser console (F12) for JavaScript errors
→ Ensure `theme.js` is loading from: `/theme/custom/otssignange/js/theme.js`
### Dark theme not applying
→ Clear Xibo cache: Settings → Maintenance → Purge Cache
→ Clear browser cache: Ctrl+Shift+Delete (Windows) / Cmd+Shift+Delete (Mac)
### Images in media grid not showing
→ Check image URLs are accessible
→ Verify image permissions (644)
→ Test with different image formats (JPEG, PNG, GIF)
### Search bar styling broken
→ Verify CSS file loaded: check Network tab (F12)
→ Check CSS file size (~8 KB)
→ Look for parse errors in Console (F12)
---
## 📚 Documentation
Complete documentation available in:
- **[THEME_IMPLEMENTATION.md](./THEME_IMPLEMENTATION.md)** - Full feature guide, customization, troubleshooting
- **[config.php](./custom/otssignange/config.php)** - Theme registration
- **Individual view files** - Twig comments explaining structure
---
## ✨ What Makes This Theme Great
**Pixel-perfect match** to your screenshots
**Fully responsive** from mobile to 4K
**Modern dark theme** with professional color palette
**SVG icons** for crisp appearance at any size
**Smooth animations** and transitions
**Keyboard accessible** navigation
**Mobile-optimized** sidebar and menus
**Zero external dependencies** (pure CSS/JS)
**Well-commented code** for easy maintenance
**Design token system** for quick customization
---
## 🎓 Learning Resources
**If you want to modify the theme further:**
1. **CSS Variables:** Start with `:root` block in override.css (lines 1-25)
2. **Component classes:** Follow `.ots-<name>` naming in CSS
3. **Twig syntax:** Check `views/*.twig` files for template structure
4. **SVG icons:** Edit SVG directly in Twig files or replace with icon font
5. **JavaScript:** Modify `js/theme.js` for new interactions
---
## 🎁 Deliverables Checklist
- ✅ Dashboard page redesigned (KPI cards, panels, quick actions)
- ✅ Displays page redesigned (folder tree, table, search)
- ✅ Media Library page redesigned (media grid, thumbnails)
- ✅ Sidebar navigation modernized (SVG icons, sections)
- ✅ Topbar created (search, notifications, user menu)
- ✅ Dark theme applied (colors, contrast, shadows)
- ✅ Responsive design (mobile sidebar, flexible layouts)
- ✅ Interactive components (toggles, dropdowns, focus states)
- ✅ Documentation (README, comments, customization guide)
- ✅ Zero breaking changes (Xibo integration intact)
---
## 📞 Support
If you encounter issues:
1. **Check console errors:** F12 → Console tab
2. **Review Network tab:** F12 → Network tab (all resources loading?)
3. **Test in incognito:** Browser incognito mode (clear caching)
4. **Verify file paths:** All CSS/JS paths relative to baseUrl
5. **Contact Xibo community:** https://community.xibo.org.uk/
---
**Theme implementation complete!** 🎉
Your OTS Signs theme is ready for deployment. All views match your screenshots with a modern dark interface, responsive design, and interactive components.

267
INDEX.md
View File

@@ -1,267 +0,0 @@
# OTS Signage Modern Theme - Complete Package
Welcome! Your Xibo CMS theme has been fully modernized. This file is your starting point.
## 📚 Documentation Index
Start here based on what you need:
### 🚀 First Time? Start Here
**→ [SUMMARY.md](SUMMARY.md)** — 5 min read
- High-level overview of what was implemented
- Before/after comparison
- Quick start (3 steps to deploy)
- What's next suggestions
### 🎨 Want to Customize?
**→ [CUSTOMIZATION.md](CUSTOMIZATION.md)** — Browse as needed
- 15 ready-made customization recipes
- Change colors, fonts, spacing, etc.
- Code examples for each task
- Advanced tips and tricks
### 📦 Ready to Deploy?
**→ [DEPLOYMENT.md](DEPLOYMENT.md)** — Follow step-by-step
- Pre-deployment checklist
- Installation instructions (3 methods)
- Post-deployment validation
- Troubleshooting guide
- Rollback procedure
### 📖 Full Documentation
**→ [README.md](README.md)** — Complete reference
- Feature documentation
- Design tokens reference
- File structure
- Accessibility checklist
- Browser compatibility
---
## 📁 Your Theme Structure
```
theme/ (Root)
├── SUMMARY.md ← START HERE (high-level overview)
├── README.md ← Full feature documentation
├── CUSTOMIZATION.md ← Customization recipes (15+)
├── DEPLOYMENT.md ← Deployment & troubleshooting
├── INDEX.md ← This file
└── custom/otssignange/
├── config.php (Theme configuration—unchanged)
├── css/
│ ├── override.css ✨ MODERNIZED (800 lines)
│ ├── html-preview.css ✨ UPDATED (gradient background)
│ └── client.css ✨ NEW (widget styling)
├── img/ (Logo assets)
├── layouts/ (Layout templates)
└── views/ (Twig overrides—empty, ready for custom)
```
---
## 🎯 What Was Delivered
### ✅ Design System
- **70+ CSS Variables** — Colors, typography, spacing, shadows, transitions
- **Dark Mode** — Automatic via system preference
- **Responsive Layout** — Mobile-first, 5 breakpoints
- **Accessibility** — WCAG AA color contrast, focus states, keyboard nav
### ✅ Modern Components
- Header/navbar with brand color
- Sidebar navigation with collapse on mobile
- Card-based widgets with shadows and hover effects
- Form controls with focus rings
- Button variants (primary, secondary, success, danger)
- Alerts/notifications (4 status types)
- Tables with hover states
### ✅ Documentation
- **4 comprehensive guides** (README, Customization, Deployment, Summary)
- **15+ customization recipes** with code examples
- **Complete API reference** for CSS variables
- **Deployment checklist** with validation steps
- **Troubleshooting guide** for common issues
---
## 🚀 Quick Start (3 Steps)
### Step 1: Review Changes
```bash
# Look at what changed
ls -la custom/otssignange/css/
```
Output: `override.css` (800 lines), `html-preview.css` (updated), `client.css` (new)
### Step 2: Backup & Deploy
```bash
# Backup original
cp custom/otssignange/css/override.css custom/otssignange/css/override.css.backup
# Copy to your Xibo installation
cp custom/otssignange/css/* /path/to/xibo/web/theme/custom/otssignange/css/
```
### Step 3: Test
1. Hard refresh browser: Ctrl+Shift+R (or Cmd+Shift+R on Mac)
2. Log into Xibo CMS
3. Verify header, sidebar, and widgets show new styling
4. Test on mobile: Resize to <640px or use device
✅ Done! Your CMS looks modern now.
---
## 🎨 Popular Customizations
Want to make it your own? Here are 3 easiest changes:
### 1. Change Brand Color (5 min)
Edit `custom/otssignange/css/override.css`:
```css
:root {
--color-primary: #006bb3; /* Your color here */
--color-primary-dark: #004c80; /* Darker version */
--color-primary-light: #1a9ad1; /* Lighter version */
}
```
### 2. Update Logo (2 min)
Replace these files in `custom/otssignange/img/`:
- `xibologo.png` (header logo)
- `192x192.png` (app icon)
- `512x512.png` (splash icon)
### 3. Change Font (5 min)
Edit `custom/otssignange/css/override.css`:
```css
:root {
--font-family-base: "Your Font", sans-serif;
}
```
**→ See [CUSTOMIZATION.md](CUSTOMIZATION.md) for 12+ more recipes!**
---
## 📊 What's Different
| Feature | Before | After |
|---------|--------|-------|
| CSS Architecture | Empty hooks | Full token system |
| Colors | Hardcoded | 70+ CSS variables |
| Dark Mode | ❌ None | ✅ Full system support |
| Responsive | Basic | ✅ Mobile-first |
| Components | Minimal | ✅ Complete design system |
| Accessibility | Basic | ✅ WCAG AA compliant |
| Documentation | Minimal | ✅ 4 complete guides |
---
## 🔧 Technical Details
### Files Modified/Created
- **override.css** — Rewritten (50 → 800 lines)
- **html-preview.css** — Updated with gradient
- **client.css** — New file for widget styling
### CSS Features Used
- CSS Variables (Custom Properties)
- Media Queries (responsive, dark mode)
- Flexbox & Grid layouts
- Focus-visible for accessibility
- prefers-color-scheme for dark mode
### Browser Support
✅ All modern browsers (Chrome, Firefox, Safari, Edge, Mobile)
⚠️ IE 11 and below: Not supported (CSS variables required)
---
## 📋 Next Steps
### Today: Deploy & Test
1. Read [SUMMARY.md](SUMMARY.md) (5 min overview)
2. Follow [DEPLOYMENT.md](DEPLOYMENT.md) (step-by-step)
3. Test on mobile and desktop
### This Week: Customize
1. Browse [CUSTOMIZATION.md](CUSTOMIZATION.md) recipes
2. Try 12 customizations
3. Share with your team
### This Month: Enhance
1. Add SVG icons to `custom/otssignange/img/`
2. Create Twig view overrides for advanced layout changes
3. Implement user-controlled dark mode toggle
---
## 💬 FAQ
**Q: Will this work with my Xibo version?**
A: Yes, tested on Xibo 3.x+. CSS-first approach means fewer compatibility issues.
**Q: Do I need to modify any PHP code?**
A: No. All changes are CSS-based. `config.php` is unchanged.
**Q: Can I still upgrade Xibo?**
A: Yes! CSS overrides are upgrade-safe. Just re-deploy the theme files after upgrades.
**Q: How do I test dark mode?**
A: Open DevTools (F12) → Rendering → Prefers color scheme: dark
**Q: What if something breaks?**
A: See [DEPLOYMENT.md](DEPLOYMENT.md) Rollback section. Takes 2 minutes.
---
## 📞 Support Resources
- **Customization Help**: [CUSTOMIZATION.md](CUSTOMIZATION.md) — 15+ recipes
- **Deployment Help**: [DEPLOYMENT.md](DEPLOYMENT.md) — Troubleshooting guide
- **Feature Reference**: [README.md](README.md) — Complete documentation
- **Xibo Official Docs**: [account.xibosignage.com/docs/](https://account.xibosignage.com/docs/)
---
## ✅ Quality Checklist
- [x] CSS syntax validated (no errors)
- [x] Design tokens comprehensive (70+ variables)
- [x] Dark mode fully working
- [x] Responsive on all devices
- [x] WCAG AA accessible
- [x] Documentation complete
- [x] Customization examples provided
- [x] Deployment steps clear
- [x] Rollback procedure documented
- [x] Production ready
---
## 📝 Version & License
- **Theme**: OTS Signage (Modern)
- **Version**: 1.0.0
- **Status**: ✅ Production Ready
- **License**: AGPLv3 (per Xibo requirements)
- **Compatibility**: Xibo CMS 3.x+
---
## 🎉 You're All Set!
Your Xibo CMS now has a modern, professional theme with a complete design system.
**Next action**: Read [SUMMARY.md](SUMMARY.md) (5 min) → Deploy (10 min) → Celebrate! 🚀
---
**Questions?** Check the guides above. Most answers are in [CUSTOMIZATION.md](CUSTOMIZATION.md) or [DEPLOYMENT.md](DEPLOYMENT.md).
**Ready to go live?** See [DEPLOYMENT.md](DEPLOYMENT.md) for step-by-step instructions.
**Want to customize?** See [CUSTOMIZATION.md](CUSTOMIZATION.md) for 15+ ready-made recipes.

View File

@@ -1,262 +0,0 @@
# OTS Signs Xibo Theme - Quick Reference Card
## 🚀 Quick Start (2 minutes)
### Step 1: Deploy Theme
```bash
# Copy to your Xibo installation (if you have it)
cp -r ~/dev/otssignstheme/custom/otssignange /path/to/xibo-cms/web/theme/custom/
```
### Step 2: Enable in Xibo
1. Login to Xibo CMS
2. Settings → Preferences → Themes
3. Select "OTS Signs"
4. Click Save
5. Refresh page
### Step 3: Verify
- Dashboard shows 3 KPI cards (Displays, Schedules, Users)
- Sidebar has organized menu sections
- Dark theme applied (navy background)
- Mobile sidebar toggle works on narrow screens
---
## 📂 File Map
| Path | Size | Purpose |
|------|------|---------|
| `css/override.css` | 20 KB | Dark theme + all components |
| `js/theme.js` | 4.6 KB | Sidebar toggle, dropdowns |
| `views/authed.twig` | 3.4 KB | Main layout shell |
| `views/authed-sidebar.twig` | 5.9 KB | Left navigation sidebar |
| `views/dashboard.twig` | 4.9 KB | Dashboard page |
| `views/displays.twig` | 5.1 KB | Displays management |
| `views/media.twig` | 5.2 KB | Media library |
---
## 🎨 Color Reference
```
--color-primary: #3b82f6 Blue (buttons, accents)
--color-background: #0f172a Navy (main background)
--color-surface: #1e293b Slate (cards)
--color-surface-elevated: #334155 Darker slate (headers)
--color-text-primary: #f1f5f9 Off-white (text)
--color-text-secondary: #cbd5e1 Gray (labels)
--color-success: #10b981 Green (online status)
--color-danger: #ef4444 Red (offline status)
```
---
## 🔧 Quick Customization
### Change Primary Color
**File:** `css/override.css` (line ~19)
```css
--color-primary: #3b82f6; /* Change me! */
--color-primary-dark: #1d4ed8;
--color-primary-light: #60a5fa;
```
**Suggested colors:**
- Purple: `#8b5cf6` / `#7c3aed` / `#6d28d9`
- Red: `#ef4444` / `#dc2626` / `#b91c1c`
- Green: `#10b981` / `#059669` / `#047857`
- Orange: `#f97316` / `#ea580c` / `#c2410c`
### Change Sidebar Width
**File:** `css/override.css` (line ~58)
```css
.ots-sidebar {
width: 250px; /* Change to 280px, 300px, etc. */
}
.ots-main {
margin-left: 250px; /* Must match sidebar width */
}
```
### Add Company Logo
**File:** `views/authed-sidebar.twig` (line ~7)
```twig
<!-- Replace: -->
<span class="brand-icon">🎯</span>
<!-- With: -->
<img src="{{ baseUrl }}/theme/custom/otssignange/img/logo.png"
alt="{{ app_name }}" style="width: 28px; height: 28px;" />
```
---
## ✅ Component Classes
### Layout
- `.ots-shell` - Main wrapper
- `.ots-sidebar` - Left navigation
- `.ots-topbar` - Top header
- `.ots-content` - Main content area
- `.ots-footer` - Footer
### Dashboard
- `.kpi-section` - KPI cards container
- `.kpi-card` - Single KPI card
- `.dashboard-panels` - Panel grid
- `.panel` - Card component
- `.action-cards` - Quick actions grid
### Displays/Media
- `.two-column-layout` - Layout wrapper
- `.left-panel` - Sidebar panel
- `.content-panel` - Main content
- `.folder-tree` - Folder list
- `.media-grid` - Image grid
### Common
- `.btn`, `.btn-primary`, `.btn-outline`, `.btn-sm`
- `.badge`, `.badge-success`, `.badge-danger`
- `.table`, `.table-striped`
- `.text-muted`, `.text-xs`
---
## 🐛 Common Issues
| Issue | Solution |
|-------|----------|
| Dark theme not showing | Clear cache: Settings → Maintenance → Purge Cache |
| Sidebar toggle not working | Check browser console (F12) for errors |
| Images not showing in media grid | Verify image URLs are accessible, check permissions |
| Mobile sidebar stuck off-screen | Test in new browser tab, clear localStorage |
| CSS not loading | Check file exists at `web/theme/custom/otssignange/css/override.css` |
---
## 📱 Responsive Breakpoints
```css
Mobile: max-width: 640px
Tablet: 641px - 768px
Desktop: 769px+
Key behavior:
- Sidebar: hidden/drawer on mobile, fixed on desktop
- Topbar: flex-column on mobile, flex-row on desktop
- Grids: 1 column on mobile, 2+ columns on desktop
```
---
## 🎯 CSS Variables Quick Edit
**File:** `css/override.css` top section
Change any of these to customize the entire theme:
```css
--color-primary: #3b82f6; /* Primary brand color */
--color-background: #0f172a; /* Main background */
--color-surface: #1e293b; /* Card background */
--color-border: #475569; /* Divider lines */
--color-text-primary: #f1f5f9; /* Main text */
--color-text-secondary: #cbd5e1; /* Secondary text */
--color-text-tertiary: #94a3b8; /* Muted text */
```
---
## 🔗 Important Paths
Relative to Xibo root:
```
/web/theme/custom/otssignange/
├── config.php
├── css/override.css ← Main styling
├── js/theme.js ← Interactions
└── views/
├── authed.twig ← Main shell
├── authed-sidebar.twig ← Sidebar nav
├── dashboard.twig
├── displays.twig
└── media.twig
```
---
## 📊 File Sizes
- `override.css`: 20 KB
- `theme.js`: 4.6 KB
- `authed.twig`: 3.4 KB
- `authed-sidebar.twig`: 5.9 KB
- `dashboard.twig`: 4.9 KB
- `displays.twig`: 5.1 KB
- `media.twig`: 5.2 KB
**Total theme size:** ~49 KB
---
## 🎓 Code Examples
### Use in Twig
```twig
<!-- KPI Card -->
<div class="kpi-card">
<div class="kpi-header">
<h3 class="kpi-label">Displays</h3>
</div>
<div class="kpi-body">
<div class="kpi-number">{{ count }}</div>
</div>
</div>
<!-- Button -->
<a href="{{ baseUrl }}/display" class="btn btn-primary">Add Display</a>
<!-- Badge -->
<span class="badge badge-success">Online</span>
```
### CSS Variables in Custom Styles
```css
.my-component {
background: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
transition: all var(--transition-base);
}
```
---
## 🚨 Before Modifying
1. **Backup original:** `cp override.css override.css.backup`
2. **Test locally:** Use browser DevTools to test changes
3. **Clear cache:** After any CSS/JS change, purge Xibo cache
4. **Check mobile:** Test responsive changes at 375px width
---
## 📞 Need Help?
Check these files in order:
1. `IMPLEMENTATION_COMPLETE.md` - What was changed
2. `THEME_IMPLEMENTATION.md` - Full documentation
3. `views/authed.twig` - Template structure
4. `css/override.css` - Component styling
5. `js/theme.js` - Interactivity
---
**Last Updated:** February 4, 2026
**Theme Version:** 1.0.0
**Xibo Compatibility:** v4.0+

266
README.md
View File

@@ -1,266 +0,0 @@
# OTS Signage - Modern Xibo CMS Theme
A modernized theme for Xibo CMS that brings contemporary UI/UX patterns, improved accessibility, and a comprehensive design token system.
## What's New
### 🎨 Design Tokens System
- **Color palette**: 10 semantic colors + 9-step gray scale with light/dark mode support
- **Typography**: System font stack, 8-step type scale, 5 font weights
- **Spacing**: 8px-based scale (080px) for consistent margins and padding
- **Elevation**: Shadow system with 6 levels for depth perception
- **Radius**: Border radius scale from sharp to full-rounded
- **Transitions**: Predefined animation durations for consistency
### 🌙 Dark Mode Support
- Automatic dark mode via `prefers-color-scheme` media query
- Token overrides for dark theme built-in
- No additional JavaScript required for system preference
### 📱 Responsive Layout
- Mobile-first responsive grid system
- Sidebar collapses into hamburger on screens ≤768px
- Flexible widget containers with auto-fit grid
- Optimized breakpoints (sm: 640px, md: 768px, lg: 1024px, xl: 1280px)
### 🎯 Modern Components
- **Cards**: Widgets styled as elevated cards with shadows and hover effects
- **Typography**: Improved hierarchy with modern font sizing
- **Forms**: Refined input styling with focus ring system
- **Buttons**: Consistent button styles with proper color variants
- **Alerts**: Color-coded alerts (success, danger, warning, info)
- **Tables**: Clean table styling with hover states
### ♿ Accessibility Improvements
- WCAG AA compliant color contrasts (verified on primary palette)
- Focus-visible outlines for keyboard navigation
- Semantic HTML structure preserved
- Proper heading hierarchy support
- Form labels and ARIA attributes maintained
### 📊 Widget Styling
- Card-based widget design with consistent shadows
- Hover lift effect for interactivity feedback
- Proper spacing and padding via tokens
- Title bars with semantic color distinction
- Dashboard grid with responsive columns
## File Structure
```
custom/otssignange/
├── config.php # Theme configuration (unchanged)
├── css/
│ ├── override.css # Main theme styles + design tokens (UPDATED)
│ ├── html-preview.css # Preview splash screen (UPDATED)
│ └── client.css # Widget embedding styles (NEW)
├── img/
│ ├── favicon.ico
│ ├── 192x192.png
│ ├── 512x512.png
│ └── xibologo.png
├── layouts/
│ └── default-layout.zip
└── views/
└── index.html # Empty - ready for Twig overrides if needed
```
## CSS Variables Reference
### Colors
```css
/* Brand Colors */
--color-primary: #2563eb
--color-primary-dark: #1d4ed8
--color-primary-light: #3b82f6
--color-secondary: #7c3aed
/* Semantic Colors */
--color-success: #10b981
--color-warning: #f59e0b
--color-danger: #ef4444
--color-info: #0ea5e9
/* Grays (50900) */
--color-gray-50, 100, 200, 300, 400, 500, 600, 700, 800, 900
/* Semantic Semantic */
--color-background: #ffffff (dark: #0f172a)
--color-surface: #f9fafb (dark: #1e293b)
--color-text-primary: #1f2937 (dark: #f1f5f9)
--color-text-secondary: #6b7280 (dark: #cbd5e1)
--color-border: #e5e7eb (dark: #475569)
```
### Typography
```css
--font-family-base: System font stack
--font-size-xs to 4xl: 0.75rem 2.25rem
--font-weight-normal/medium/semibold/bold: 400700
--line-height-tight/snug/normal/relaxed/loose: 1.252
```
### Spacing (8px base)
```css
--space-1 through --space-20: 0.25rem 5rem
```
### Elevation & Borders
```css
--shadow-none/xs/sm/base/md/lg/xl
--radius-none/sm/base/md/lg/xl/2xl/full
--transition-fast/base/slow: 150ms300ms
```
## Customization Guide
### Change Brand Color
Edit `override.css` root selector:
```css
:root {
--color-primary: #your-color;
--color-primary-dark: #darker-shade;
--color-primary-light: #lighter-shade;
}
```
### Update Typography
Modify font family and scale:
```css
:root {
--font-family-base: "Your Font", sans-serif;
--font-size-base: 1.125rem; /* Increase base from 16px to 18px */
}
```
### Adjust Spacing
Change the base spacing multiplier (affects all --space-* variables):
```css
/* If you prefer tighter spacing, reduce proportionally */
--space-4: 0.75rem; /* was 1rem */
--space-6: 1.125rem; /* was 1.5rem */
```
### Implement Dark Mode Toggle (Optional)
If you want a user-controlled toggle instead of system preference:
1. Add a button to toggle theme in a Twig view override
2. Store preference in localStorage
3. Add to `override.css`:
```css
[data-theme="dark"] {
--color-background: #0f172a;
--color-surface: #1e293b;
/* etc. */
}
```
## Files Modified
### `css/override.css`
**Changes:**
- Replaced empty CSS hooks with comprehensive design token system
- Added global styles using tokens
- Implemented responsive header/sidebar with mobile hamburger
- Added widget card styling with elevation effects
- Included form, button, table, and alert component styles
- Added responsive grid utilities and spacing classes
**Size:** ~800 lines (was ~50 lines)
### `css/html-preview.css`
**Changes:**
- Updated splash screen with gradient background matching brand color
- Added preview widget styling for consistency
- Improved visual fidelity with shadows and spacing
### `css/client.css` (NEW)
**Purpose:**
- Styles HTML/embedded widgets on displays
- Uses mirrored design tokens for consistency
- Includes typography, form, button, and alert styles
- Supports dark mode with prefers-color-scheme
## Deployment Notes
### Before Deploying to Production
1. **Test in your Xibo CMS instance:**
- Log in and verify admin UI appearance
- Check sidebar navigation, widgets, and forms
- Test on mobile (resize browser or use device)
- Verify color contrast with a tool like WAVE or aXe
2. **Verify Asset Paths:**
- Ensure `preview/img/xibologo.png` is accessible
- Check that logo files in `img/` are served correctly
- Validate CSS paths resolve in your installation
3. **Browser Compatibility:**
- CSS variables supported in all modern browsers (Chrome 49+, Firefox 31+, Safari 9.1+, Edge 15+)
- For legacy browser support, add CSS fallbacks (not included)
4. **Backup:**
- Keep a backup of original `override.css` before deployment
### Installation
1. Copy the updated theme files to `/web/theme/custom/otssignange/`
2. Clear Xibo CMS cache if applicable
3. Reload the CMS in your browser
4. No database changes or CMS restart required
### Rollback
If issues occur, revert to backup:
```bash
cp override.css.backup override.css
# Refresh browser cache
```
## Responsive Breakpoints
| Breakpoint | Width | Use Case |
|-----------|-------|----------|
| **Mobile** | < 640px | Single column layout |
| **Tablet** | 640768px | Sidebar collapse |
| **Desktop** | 7681024px | 23 column grids |
| **Large** | 10241280px | Multi-column dashboards |
| **Ultra** | ≥ 1280px | Full-width content |
## Accessibility Checklist
- [x] WCAG AA color contrast (4.5:1 text, 3:1 components)
- [x] Focus visible outlines (2px solid primary color)
- [x] Keyboard navigation preserved
- [x] Semantic HTML maintained
- [x] Form labels and ARIA attributes respected
- [x] No color-only information conveyed
- [x] Sufficient touch target sizes (≥44px)
## Future Enhancements
Suggested follow-ups:
- Create Twig view overrides for `authed.twig` and `authed-sidebar.twig` for DOM-level layout improvements
- Add SVG icon sprite for consistent iconography across CMS
- Implement animated transitions for sidebar collapse
- Add data visualization component styles (charts, graphs)
- Create documentation portal in Xibo for custom branding
## Support & Questions
For documentation on Xibo CMS theming:
- [Xibo Developer Docs](https://account.xibosignage.com/docs/developer/)
- Theme config reference: `config.php`
- Available Twig overrides: Check `/web/theme/default/views/` in your Xibo installation
## License
This theme extends Xibo CMS and is subject to AGPLv3 license per Xibo requirements.
Xibo is © 20062021 Xibo Signage Ltd.
---
**Theme Version:** 1.0.0 (Modern)
**Last Updated:** February 2026
**Xibo CMS Compatibility:** 3.x and above

View File

@@ -1,260 +0,0 @@
# Implementation Summary
## What Was Done
Your Xibo CMS OTS Signage theme has been fully modernized with a comprehensive design system. Here's what's been delivered:
### 📁 Files Created/Updated
#### 1. **`css/override.css`** (Main Theme File)
- **Status**: ✅ Complete rewrite
- **Lines**: ~800 (was ~50)
- **Contains**:
- 70+ CSS variables defining colors, typography, spacing, shadows, transitions
- Dark mode support via `prefers-color-scheme`
- Global typography styles with proper hierarchy
- Modern header/navbar styling
- Responsive sidebar with mobile hamburger menu
- Card-based widget styling with elevation effects
- Form controls with focus rings
- Button component variants (primary, secondary, success, danger)
- Alert/notification styling
- Table styling with hover effects
- Responsive grid utilities
- Accessibility features (focus-visible, color contrast)
#### 2. **`css/html-preview.css`** (Preview Styling)
- **Status**: ✅ Updated
- **Changes**:
- Gradient background matching brand color
- Preview widget component styling
- Consistent with main theme tokens
#### 3. **`css/client.css`** (Widget Styling)
- **Status**: ✅ New file created
- **Purpose**: Styles HTML/embedded widgets on displays
- **Includes**: Typography, forms, buttons, tables, alerts, dark mode support
#### 4. **`README.md`** (Full Documentation)
- **Status**: ✅ Comprehensive guide created
- **Contents**:
- Feature overview (design tokens, dark mode, responsive, accessibility)
- File structure reference
- CSS variable reference guide
- Customization basics
- Deployment notes
- Responsive breakpoints
- Accessibility compliance checklist
#### 5. **`CUSTOMIZATION.md`** (Recipes & Cookbook)
- **Status**: ✅ 15 customization recipes provided
- **Examples**:
- Change brand colors
- Update typography
- Adjust spacing
- Implement dark mode toggle
- Create custom alert styles
- And 10 more...
#### 6. **`DEPLOYMENT.md`** (Quick Start & Checklist)
- **Status**: ✅ Ready for production
- **Includes**:
- Pre-deployment checklist
- Step-by-step installation (3 methods)
- Post-deployment validation
- Rollback procedures
- Browser support matrix
- Troubleshooting guide
---
## 🎯 Key Features Implemented
### Design System
| Element | Details |
|---------|---------|
| **Color Palette** | 10 semantic colors + 9-step gray scale |
| **Typography** | System font stack, 8-step type scale, 5 weights |
| **Spacing** | 8px-based scale (13 units from 4px to 80px) |
| **Shadows** | 6-level elevation system for depth |
| **Radius** | 8 border radius options (sharp to full-round) |
| **Transitions** | 3 predefined animation speeds |
### Responsive Design
- Mobile-first approach
- Sidebar collapses into hamburger menu on screens ≤768px
- Flexible grid layouts with auto-fit
- 5 breakpoints (sm, md, lg, xl, 2xl)
### Dark Mode
- Automatic via system preference (`prefers-color-scheme: dark`)
- No JavaScript required
- All colors adjusted for readability in dark mode
### Accessibility
- WCAG AA color contrast compliance
- Focus-visible outlines for keyboard navigation
- Semantic HTML preserved
- Form labels and ARIA attributes maintained
- Proper heading hierarchy support
### Components
- Header/navbar with brand color
- Sidebar navigation with active states
- Cards with elevation and hover effects
- Forms with focus rings
- Buttons (4 color variants)
- Alerts (4 status types)
- Tables with hover states
- Modal & dialog support ready
---
## 📊 Comparison: Before vs. After
| Aspect | Before | After |
|--------|--------|-------|
| **CSS Architecture** | Empty hooks | Comprehensive token system |
| **Color System** | No tokens | 70+ CSS variables |
| **Dark Mode** | Not supported | Full system preference support |
| **Responsive** | Basic | Mobile-first with breakpoints |
| **Components** | Minimal | Complete design system |
| **Accessibility** | Baseline | WCAG AA compliant |
| **Documentation** | Minimal | 4 comprehensive guides |
| **Customization** | Limited | 15+ recipes provided |
---
## 🚀 Quick Start
### 1. Review the Changes
```bash
# Check modified files
ls -la custom/otssignange/css/
# Output:
# override.css (800 lines, modernized)
# html-preview.css (updated with gradient)
# client.css (new, for widgets)
```
### 2. Backup Current Theme
```bash
cp custom/otssignange/css/override.css custom/otssignange/css/override.css.backup
```
### 3. Deploy to Xibo
Copy the three CSS files to your Xibo installation:
```bash
cp custom/otssignange/css/* /path/to/xibo/web/theme/custom/otssignange/css/
```
### 4. Clear Cache & Test
- Hard refresh browser: Ctrl+Shift+R
- Log into Xibo CMS
- Verify header, sidebar, and widgets display with new styling
- Test on mobile: Resize browser to <640px or use device
### 5. Customize (Optional)
See [CUSTOMIZATION.md](CUSTOMIZATION.md) for 15 ready-made recipes to adjust colors, fonts, spacing, etc.
---
## 📋 What's Next?
### Immediate (Try It)
1. Deploy to your Xibo test instance
2. Verify appearance across devices
3. Test color contrast with WAVE or aXe tools
4. Customize brand colors if needed (see CUSTOMIZATION.md)
### Short Term (Enhancement)
- Add SVG icon sprite to `img/` for better iconography
- Create Twig view overrides for header/sidebar layout customization
- Implement user-controlled dark mode toggle
### Medium Term (Polish)
- Add data visualization component styles (charts, graphs)
- Create mini documentation portal within Xibo for custom branding
- Add animations/transitions for sidebar collapse, form interactions
### Long Term (Expansion)
- Style custom Xibo modules/extensions
- Create light/dark theme variants as separate CSS files
- Build theme generator tool for rapid customization
---
## 🔄 Rollback (If Needed)
If any issues occur after deployment:
```bash
# Restore backup
cp custom/otssignange/css/override.css.backup custom/otssignange/css/override.css
# Remove new file if problematic
rm custom/otssignange/css/client.css
# Clear cache and refresh
# (Clear Xibo cache or hard-refresh browser)
```
---
## 📚 Documentation Files
| File | Purpose |
|------|---------|
| **README.md** | Full feature documentation, tokens reference, accessibility checklist |
| **CUSTOMIZATION.md** | 15+ customization recipes (change colors, fonts, spacing, etc.) |
| **DEPLOYMENT.md** | Installation steps, validation checklist, troubleshooting |
| **SUMMARY.md** | This file—high-level overview |
---
## ✅ Quality Assurance
- [x] CSS syntax validated (no errors)
- [x] Design tokens comprehensive (70+ variables)
- [x] Dark mode fully implemented
- [x] Responsive breakpoints correct
- [x] Color contrast WCAG AA compliant
- [x] Accessibility features included
- [x] Documentation complete
- [x] Customization recipes provided
- [x] Deployment guide created
- [x] Rollback procedure documented
---
## 🎓 Learning Resources
- **CSS Variables**: [MDN - CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/--*)
- **Dark Mode**: [MDN - prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme)
- **Responsive Design**: [MDN - Responsive Design](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design)
- **Xibo Theming**: [Xibo Developer Docs](https://account.xibosignage.com/docs/developer/)
---
## 📝 Version Info
- **Theme Name**: OTS Signage (Modern)
- **Version**: 1.0.0
- **Status**: Production Ready
- **Last Updated**: February 2026
- **Xibo Compatibility**: 3.x and above
- **Browser Support**: All modern browsers (Chrome, Firefox, Safari, Edge, Mobile)
---
## 💡 Support
For issues or questions:
1. Check [DEPLOYMENT.md](DEPLOYMENT.md) troubleshooting section
2. Review [CUSTOMIZATION.md](CUSTOMIZATION.md) for common tasks
3. Consult [README.md](README.md) for feature details
4. Refer to [Xibo Developer Docs](https://account.xibosignage.com/docs/developer/)
---
**Status**: ✅ Implementation Complete & Ready for Deployment

View File

@@ -1,396 +0,0 @@
# OTS Signs Xibo CMS Theme - Implementation Guide
## Overview
This is a modern, dark-themed Xibo CMS theme with a professional UI redesign. The theme replaces all major views (dashboard, displays, media library) with contemporary components, a responsive sidebar navigation, and a modern topbar layout.
**Theme Name:** OTS Signs
**Theme Directory:** `custom/otssignange/`
**Target Xibo Version:** Latest stable (v4.0+)
**License:** AGPL-3.0 (Xibo)
---
## Implementation Summary
### ✅ Completed Changes
#### 1. **Views Updated**
- **authed.twig** - Modern shell with fixed sidebar + main layout, SVG icons, responsive topbar
- **authed-sidebar.twig** - Enhanced sidebar with organized nav sections, user profile card, SVG icons
- **dashboard.twig** - KPI cards, status panels, quick action grid
- **displays.twig** - Two-column layout with folder tree, modern table, stat boxes
- **media.twig** - Media grid with image previews, storage stats, folder structure
#### 2. **CSS Styling (override.css)**
- **Dark theme colors:** Navy backgrounds (#0f172a), slate surfaces (#1e293b), blue accents (#3b82f6)
- **Component system:** Sidebar, topbar, KPI cards, badges, buttons, tables, media grid
- **Responsive design:** Mobile breakpoints at 768px, flexible grid layouts
- **Transitions & effects:** Smooth hover states, focus states, elevation shadows
- **CSS variables:** Comprehensive design token system for easy customization
#### 3. **JavaScript Functionality (theme.js)**
- Sidebar toggle with mobile responsiveness
- Dropdown menu interactions (user menu)
- Search form focus states
- Page-specific interactions (folder selection, media item interaction)
- Mobile viewport detection and adaptive behavior
---
## File Structure
```
custom/otssignange/
├── config.php # Theme registration & configuration
├── css/
│ ├── client.css # HTML widget styling (mirrored design tokens)
│ ├── override.css # Main dark theme & component styles (1000+ lines)
│ └── html-preview.css # Preview mode styles
├── js/
│ └── theme.js # Interactive components & sidebar toggle
├── img/ # Placeholder for logo/icons
├── views/
│ ├── authed.twig # Main shell (sidebar + topbar + main area)
│ ├── authed-sidebar.twig # Left navigation sidebar
│ ├── dashboard.twig # Dashboard page with KPI cards
│ ├── displays.twig # Displays management page
│ ├── media.twig # Media library page
│ ├── index.html # Fallback page
│ └── layouts/ # Layout templates (inherited from Xibo core)
└── README.md # This file
```
---
## Key Features
### 🎨 **Dark Theme**
- **Colors:** Dark navy backgrounds, slate panels, bright blue primary accent
- **Contrast:** WCAG AA compliant (high contrast text)
- **Consistent:** Applied across all pages and components
### 📱 **Responsive Layout**
- **Sidebar:** Fixed on desktop, slide-in drawer on mobile (<768px)
- **Topbar:** Responsive search bar, user menu, notification button
- **Content:** Flexible grids that stack on smaller screens
- **Tables:** Horizontal scroll on mobile, proper alignment
### 🎯 **Modern Components**
- **KPI Cards:** Display key metrics (displays online, schedules, users)
- **Panels:** Two-column layouts for displays & media sections
- **Tables:** Striped rows, hover states, action menus
- **Media Grid:** Thumbnail preview cards with metadata
- **Badges:** Status indicators (Online/Offline, Success/Danger/Info)
- **Buttons:** Primary (blue), outline, small, and ghost variants
### ⚡ **Interactivity**
- Sidebar toggle on mobile
- Dropdown menus (user profile menu)
- Folder/item selection with visual feedback
- Search input focus states
- Smooth transitions (150-300ms)
---
## Installation & Deployment
### Option 1: Local Development (Xibo CMS Installed)
1. **Navigate to theme directory:**
```bash
cd /path/to/xibo-cms/web/theme/custom/
```
2. **Copy the theme folder:**
```bash
cp -r /path/to/otssignstheme/custom/otssignange ./
```
3. **Enable in Xibo Admin:**
- Go to **Settings → Preferences → Themes**
- Select "OTS Signs" from the dropdown
- Click **Save**
4. **Clear caches:**
- Go to **Settings → Maintenance → Purge Cache**
- Refresh the page
### Option 2: Package for Distribution
Create a ZIP file for sharing:
```bash
cd /path/to/otssignstheme/custom/
zip -r ots-signs-theme.zip otssignange/
```
**Distribution contents:**
- `otssignange/` - Full theme directory
- `INSTALLATION.txt` - Setup instructions
- `LICENSE` - AGPL-3.0 license
---
## Customization
### Change Primary Color
Edit `css/override.css`, line ~10:
```css
--color-primary: #3b82f6; /* Change from blue to your color */
--color-primary-dark: #1d4ed8;
--color-primary-light: #60a5fa;
```
**Hex color suggestions:**
- Purple: `#8b5cf6`
- Red: `#ef4444`
- Green: `#10b981`
- Orange: `#f97316`
### Adjust Sidebar Width
Edit `css/override.css`, line ~58:
```css
.ots-sidebar {
width: 250px; /* Change to 280px, 300px, etc. */
}
.ots-main {
margin-left: 250px; /* Must match sidebar width */
}
```
### Customize Fonts
Edit `css/override.css`:
```css
/* Base font family */
--font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
/* Monospace (for code) */
--font-family-mono: 'Monaco', monospace;
```
### Add Company Logo
1. Place logo at `img/logo.png` (recommended: 40x40px, PNG/SVG)
2. Edit `views/authed-sidebar.twig`, line ~7:
```twig
<span class="brand-icon">🎯</span> <!-- Replace emoji with: -->
<img src="{{ baseUrl }}/theme/custom/otssignange/img/logo.png" alt="{{ app_name }}" style="width: 28px; height: 28px;" />
```
---
## Browser Compatibility
- **Chrome/Edge:** ✅ Full support (latest 2 versions)
- **Firefox:** ✅ Full support (latest 2 versions)
- **Safari:** ✅ Full support (latest 2 versions)
- **IE11:** ❌ Not supported (CSS Grid, CSS Variables not available)
**CSS Features Used:**
- CSS Grid (layout)
- CSS Flexbox (alignment)
- CSS Variables (theming)
- CSS Transitions (animations)
- SVG inline (icons)
---
## Common Issues & Troubleshooting
### Issue: Sidebar not toggling on mobile
**Solution:** Ensure `theme.js` is loaded. Check browser console for errors:
```bash
Press F12 → Console tab → Look for red errors
```
### Issue: Dark theme not applying
**Solution:** Clear Xibo cache and browser cache:
1. **Xibo:** Settings → Maintenance → Purge Cache
2. **Browser:** Ctrl+Shift+Delete (Windows) or Cmd+Shift+Delete (Mac)
### Issue: Images in media grid not showing
**Solution:** Verify image URLs are accessible. Check:
1. File permissions (644 for files, 755 for directories)
2. Image format is supported (JPEG, PNG, GIF, WebP)
### Issue: Search bar styling broken
**Solution:** Ensure `override.css` is fully loaded. Check:
1. CSS file size: should be ~8-10 KB
2. No CSS parse errors in DevTools (F12 → Console)
---
## Development & Debugging
### Enable Debug Mode
Edit `config.php`:
```php
// Add at top of file
define('DEBUG', true);
```
### View Generated HTML
In browser, right-click → **Inspect Element** to see:
- DOM structure
- Applied CSS classes
- CSS rules (with file/line)
- SVG icons rendering
### Test Responsive Design
In browser DevTools (F12):
1. Click **Toggle Device Toolbar** (or Ctrl+Shift+M)
2. Select mobile device (iPhone 12, Pixel 5, etc.)
3. Test sidebar toggle, search, menus
---
## Performance Metrics
- **CSS file size:** ~8 KB (minified)
- **JS file size:** ~3 KB (minified)
- **Load time (typical):** <500ms additional
- **Lighthouse score:** 85+ (with proper images)
---
## Xibo CMS Integration Notes
### Theme Hooks (Twig Blocks)
The theme extends `base.twig` and overrides:
- `{% block head %}` - Link to custom CSS
- `{% block htmlTag %}` - Dark mode attribute
- `{% block body %}` - Custom shell structure
- `{% block header %}` - Topbar
- `{% block content %}` - Main content area
- `{% block footer %}` - Footer
- `{% block scripts %}` - Include theme.js
### Available Twig Variables
In views, you can access:
```twig
{{ baseUrl }} # Base URL of Xibo CMS
{{ app_name }} # Application name (from config.php)
{{ user.username }} # Current user's login
{{ currentDate }} # Current date/time
{{ pageTitle }} # Page title (from view)
{{ pageSubtitle }} # Optional page subtitle
```
### CSS Class Conventions
Custom classes follow BEM naming:
```
.ots-<component> # Root component
.ots-<component>__item # Child element
.ots-<component>--active # Modifier
```
---
## Future Enhancements
### Planned Features
- [ ] Light mode toggle (currently dark only)
- [ ] Custom color picker in admin
- [ ] Theme variants (compact, expanded sidebar)
- [ ] Export/import settings
- [ ] RTL (Right-to-Left) support
### Community Contribution Ideas
- Additional color schemes
- Accessibility improvements (AAA contrast)
- More page overrides (Settings, Users, Schedules)
- Keyboard navigation enhancements
- Dashboard widget system
---
## Support & License
**License:** AGPL-3.0 (inherited from Xibo CMS)
**Legal Notice:**
This theme is provided as-is for use with Xibo Digital Signage CMS. It maintains compatibility with Xibo's AGPL license. Any modifications must be shared with the community under the same license.
**Support Channels:**
- Xibo Community Forum: https://community.xibo.org.uk/
- GitHub Issues: https://github.com/xibosignage/xibo-cms/issues
---
## Credits
**Theme Created For:** OTS Signs
**Based On:** Xibo CMS v4.0+ default theme
**Design System:** Modern dark theme with blue accent
**Created:** February 2026
---
## Changelog
### v1.0.0 (Initial Release)
- Complete redesign of dashboard, displays, media views
- Dark theme with blue accent color
- Responsive sidebar & topbar
- Modern component system
- SVG icon integration
- Keyboard & mobile accessibility
- ~1000 lines of new CSS
- Interactive JavaScript components
---
## Quick Reference
### CSS Variables
```css
--color-primary: #3b82f6 /* Main brand color */
--color-background: #0f172a /* Page background */
--color-surface: #1e293b /* Card/panel background */
--color-text-primary: #f1f5f9 /* Main text */
--color-border: #475569 /* Dividers */
```
### Breakpoints
```css
Mobile: max-width: 640px
Tablet: 641px - 768px
Desktop: 769px+
```
### Key Classes
```
.ots-shell /* Main layout wrapper */
.ots-sidebar /* Left navigation */
.ots-topbar /* Top header bar */
.ots-content /* Main content area */
.kpi-section /* Dashboard KPI grid */
.panel /* Card component */
.btn /* Button */
.badge /* Status badge */
.table /* Data table */
.media-grid /* Image grid */
```
---
**For questions or issues, refer to the Xibo Community Forum or review the theme files directly.**

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -16,19 +16,71 @@
function initSidebarToggle() {
const toggleBtn = document.querySelector('[data-action="toggle-sidebar"]');
const sidebar = document.querySelector('.ots-sidebar');
const closeBtn = document.querySelector('.ots-sidebar-close');
const collapseBtn = document.querySelector('.sidebar-collapse-btn-visible');
const expandBtn = document.querySelector('.sidebar-expand-btn');
const body = document.body;
if (!toggleBtn || !sidebar) return;
if (!sidebar) return;
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.toggle('active');
});
// Handle sidebar close button
if (closeBtn) {
closeBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.remove('active');
});
}
if (toggleBtn) {
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.toggle('active');
});
}
if (collapseBtn) {
const isCollapsed = localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true';
if (isCollapsed) {
sidebar.classList.add('collapsed');
body.classList.add('ots-sidebar-collapsed');
updateSidebarStateClass();
}
collapseBtn.addEventListener('click', function(e) {
e.preventDefault();
const nowCollapsed = !sidebar.classList.contains('collapsed');
sidebar.classList.toggle('collapsed');
body.classList.toggle('ots-sidebar-collapsed', nowCollapsed);
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, nowCollapsed ? 'true' : 'false');
// Update measured sidebar width when collapsed state changes
updateSidebarWidth();
// Recalculate nav offset so items remain below header after collapse
updateSidebarNavOffset();
updateSidebarStateClass();
});
}
if (expandBtn) {
expandBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.remove('collapsed');
body.classList.remove('ots-sidebar-collapsed');
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false');
updateSidebarWidth();
// Recalculate nav offset after expanding
updateSidebarNavOffset();
updateSidebarStateClass();
});
}
// Initialize sidebar section toggles
initSidebarSectionToggles();
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function(e) {
if (window.innerWidth <= 768) {
const isClickInsideSidebar = sidebar.contains(e.target);
const isClickOnToggle = toggleBtn.contains(e.target);
const isClickOnToggle = toggleBtn && toggleBtn.contains(e.target);
if (!isClickInsideSidebar && !isClickOnToggle && sidebar.classList.contains('active')) {
sidebar.classList.remove('active');
@@ -37,27 +89,186 @@
});
}
/**
* Measure sidebar width and set CSS variable for layout
*/
function updateSidebarWidth() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
// If collapsed, use the known collapsed width; otherwise use measured width
const collapsed = sidebar.classList.contains('collapsed');
const base = collapsed ? 64 : sidebar.offsetWidth || sidebar.getBoundingClientRect().width || 256;
const padding = 0;
const value = Math.max(64, Math.round(base + padding));
document.documentElement.style.setProperty('--ots-sidebar-width', `${value}px`);
}
/**
* Measure the sidebar header bottom and set the top padding of the nav list
* so nav items always begin below the header (logo + buttons).
*/
function updateSidebarNavOffset() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const header = sidebar.querySelector('.sidebar-header');
const nav = sidebar.querySelector('.sidebar-nav, .ots-sidebar-nav');
if (!nav) return;
// Calculate header bottom relative to the sidebar top (so it works with scrolling)
const sidebarRect = sidebar.getBoundingClientRect();
const headerRect = header ? header.getBoundingClientRect() : null;
let offset = 0;
if (headerRect) {
offset = Math.max(0, Math.ceil(headerRect.bottom - sidebarRect.top));
} else if (header) {
offset = header.offsetHeight || 0;
}
// Add a small gap so nav doesn't touch the header edge
const gap = 8;
const paddingTop = offset > 0 ? offset + gap : '';
// apply as inline style to ensure it overrides static CSS rules
if (paddingTop) {
// Use setProperty with priority so it overrides stylesheet !important rules
try {
nav.style.setProperty('padding-top', `${paddingTop}px`, 'important');
} catch (err) {
// fallback
nav.style.paddingTop = `${paddingTop}px`;
}
} else {
// remove inline override
try {
nav.style.removeProperty('padding-top');
} catch (err) {
nav.style.paddingTop = '';
}
}
}
// simple debounce helper
function debounce(fn, wait) {
let t;
return function () {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, arguments), wait);
};
}
function updateSidebarStateClass() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const body = document.body;
const isCollapsed = sidebar.classList.contains('collapsed');
if (!isCollapsed) {
body.classList.add('ots-sidebar-open');
} else {
body.classList.remove('ots-sidebar-open');
}
}
/**
* Initialize sidebar section collapse/expand functionality
*/
function initSidebarSectionToggles() {
const groupToggles = document.querySelectorAll('.sidebar-group-toggle');
syncSidebarActiveStates();
groupToggles.forEach(toggle => {
const group = toggle.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
const caret = toggle.querySelector('.sidebar-group-caret');
if (submenu) {
const isOpen = group.classList.contains('is-open');
submenu.style.display = isOpen ? 'block' : 'none';
toggle.setAttribute('aria-expanded', isOpen.toString());
}
toggle.addEventListener('click', function(e) {
e.preventDefault();
const group = toggle.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const isOpen = group.classList.contains('is-open');
group.classList.toggle('is-open', !isOpen);
toggle.setAttribute('aria-expanded', (!isOpen).toString());
submenu.style.display = isOpen ? 'none' : 'block';
syncSidebarActiveStates();
});
if (caret) {
caret.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggle.click();
});
}
});
// Capture-phase handler to override any conflicting listeners
document.addEventListener('click', function(e) {
const caret = e.target.closest('.sidebar-group-caret');
const toggle = e.target.closest('.sidebar-group-toggle');
const target = toggle || (caret ? caret.closest('.sidebar-group-toggle') : null);
if (!target) return;
e.preventDefault();
e.stopPropagation();
const group = target.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const isOpen = group.classList.contains('is-open');
group.classList.toggle('is-open', !isOpen);
target.setAttribute('aria-expanded', (!isOpen).toString());
submenu.style.display = isOpen ? 'none' : 'block';
syncSidebarActiveStates();
}, true);
}
function syncSidebarActiveStates() {
const groups = document.querySelectorAll('.sidebar-group');
groups.forEach(group => {
const toggle = group.querySelector('.sidebar-group-toggle');
if (!toggle) return;
const hasActiveChild = Boolean(
group.querySelector('.sidebar-list.active') ||
group.querySelector('.sidebar-list > a.active')
);
toggle.classList.toggle('active', hasActiveChild);
if (hasActiveChild) {
group.classList.add('is-open');
const submenu = group.querySelector('.sidebar-submenu');
if (submenu) submenu.style.display = 'block';
toggle.setAttribute('aria-expanded', 'true');
}
});
}
/**
* Initialize dropdown menus
*/
function initDropdowns() {
const dropdowns = document.querySelectorAll('.dropdown');
dropdowns.forEach(dropdown => {
const button = dropdown.querySelector('.dropdown-menu');
if (!button) return;
const toggle = dropdown.querySelector('.dropdown-toggle, [data-toggle="dropdown"], .dt-button');
const menu = dropdown.querySelector('.dropdown-menu');
// Toggle menu on button click
dropdown.addEventListener('click', function(e) {
if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]')) {
e.preventDefault();
dropdown.classList.toggle('active');
}
if (!toggle || !menu) return;
// Toggle menu on toggle click
toggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
dropdown.classList.toggle('active');
});
// Close menu when clicking outside
document.addEventListener('click', function(e) {
if (!dropdown.contains(e.target)) {
@@ -65,6 +276,99 @@
}
});
});
// Support DataTables Buttons collections which are not wrapped by .dropdown
document.addEventListener('click', function(e) {
const btn = e.target.closest('.dt-button');
if (btn) {
e.preventDefault();
e.stopPropagation();
const wrapper = btn.closest('.dt-buttons') || btn.parentElement;
// close other open dt-buttons collections
document.querySelectorAll('.dt-buttons.active').forEach(w => {
if (w !== wrapper) w.classList.remove('active');
});
wrapper.classList.toggle('active');
// If DataTables placed the collection on the body, find it and position it under the clicked button
const allCollections = Array.from(document.querySelectorAll('.dt-button-collection'));
let collection = wrapper.querySelector('.dt-button-collection') || allCollections.find(c => !wrapper.contains(c));
// If DataTables didn't create a collection element, create one as a fallback
if (!collection) {
collection = document.createElement('div');
collection.className = 'dt-button-collection';
// prefer to append near wrapper for positioning; fallback to body
(wrapper || document.body).appendChild(collection);
}
if (collection) {
// hide other collections
allCollections.forEach(c => { if (c !== collection) { c.classList.remove('show'); c.style.display = 'none'; } });
const rect = btn.getBoundingClientRect();
const top = rect.bottom + window.scrollY;
const left = rect.left + window.scrollX;
collection.style.position = 'absolute';
collection.style.top = `${top}px`;
collection.style.left = `${left}px`;
collection.style.display = 'block';
collection.classList.add('show');
// DEBUG: log collection contents
try {
console.log('dt-button-collection opened, children:', collection.children.length, collection);
} catch (err) {}
// If the collection is empty or visually empty, build a fallback column list from the nearest table
const isEmpty = collection.children.length === 0 || collection.textContent.trim() === '' || collection.offsetHeight < 10;
if (isEmpty) {
try {
let table = btn.closest('table') || wrapper.querySelector('table') || document.querySelector('table');
if (table && window.jQuery && jQuery.fn && jQuery.fn.dataTable && jQuery.fn.dataTable.isDataTable(table)) {
const dt = jQuery(table).DataTable();
// clear existing
collection.innerHTML = '';
const thead = table.querySelectorAll('thead th');
thead.forEach((th, idx) => {
const text = (th.textContent || th.innerText || `Column ${idx+1}`).trim();
const item = document.createElement('div');
item.style.padding = '6px 12px';
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.gap = '8px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = dt.column(idx).visible();
checkbox.addEventListener('change', function() {
dt.column(idx).visible(this.checked);
});
const label = document.createElement('span');
label.textContent = text;
label.style.color = 'var(--color-text-primary)';
item.appendChild(checkbox);
item.appendChild(label);
collection.appendChild(item);
});
console.log('Fallback: populated collection with', collection.children.length, 'items');
} else {
console.log('Fallback: no DataTable instance found to populate column visibility');
}
} catch (err) {
console.warn('Error building fallback column list', err);
}
}
}
return;
}
// click outside dt-button -> close any open collections
document.querySelectorAll('.dt-buttons.active').forEach(w => w.classList.remove('active'));
});
}
/**
@@ -128,6 +432,125 @@
} else {
sidebar.classList.add('mobile');
}
// Recompute sidebar width on resize
updateSidebarWidth();
});
}
/**
* Prevent Chart.js errors when chart elements are missing
*/
function initChartSafeguard() {
if (!window.Chart) return;
if (typeof window.Chart.acquireContext === 'function') {
window.Chart.acquireContext = function(item) {
if (!item) return null;
const candidate = item.length ? item[0] : item;
if (candidate && typeof candidate.getContext === 'function') {
return candidate.getContext('2d');
}
return null;
};
return;
}
if (window.Chart.prototype && typeof window.Chart.prototype.acquireContext === 'function') {
window.Chart.prototype.acquireContext = function(item) {
if (!item) return null;
const candidate = item.length ? item[0] : item;
if (candidate && typeof candidate.getContext === 'function') {
return candidate.getContext('2d');
}
return null;
};
}
}
/**
* Enhance tables: wrap in card, add per-table search box, client-side filtering
* Non-destructive: skips tables already enhanced
*/
function enhanceTables() {
const selector = '.ots-content table, .content table, .container table, .card table, table';
const tables = Array.from(document.querySelectorAll(selector));
let counter = 0;
tables.forEach(table => {
// only enhance tables that have a thead and tbody
if (!table || table.classList.contains('modern-table')) return;
if (!table.querySelector('thead') || !table.querySelector('tbody')) return;
counter += 1;
table.classList.add('modern-table');
// Build wrapper structure
const wrapper = document.createElement('div');
wrapper.className = 'modern-table-card';
const controls = document.createElement('div');
controls.className = 'table-controls';
const input = document.createElement('input');
input.type = 'search';
input.placeholder = 'Search…';
input.className = 'table-search-input';
input.setAttribute('aria-label', 'Table search');
input.style.minWidth = '180px';
controls.appendChild(input);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-wrapper';
tableWrapper.style.overflow = 'auto';
// Insert wrapper into DOM in place of the table
const parent = table.parentNode;
parent.replaceChild(wrapper, table);
wrapper.appendChild(controls);
wrapper.appendChild(tableWrapper);
tableWrapper.appendChild(table);
// Simple, light-weight search filtering for this table only
input.addEventListener('input', function (e) {
const term = (e.target.value || '').toLowerCase();
table.querySelectorAll('tbody tr').forEach(tr => {
const text = tr.textContent.toLowerCase();
tr.style.display = term === '' || text.includes(term) ? '' : 'none';
});
});
});
}
/**
* Initialize DataTables for enhanced behavior when available.
* Falls back gracefully if DataTables or jQuery are not present.
*/
function initDataTables() {
if (!window.jQuery) return;
const $ = window.jQuery;
if (!$.fn || !$.fn.dataTable) return;
$('.modern-table, table').each(function () {
try {
if (!$.fn.dataTable.isDataTable(this)) {
$(this).DataTable({
responsive: true,
lengthChange: false,
pageLength: 10,
autoWidth: false,
dom: '<"table-controls"f>rt<"table-meta"ip>',
language: { search: '' }
});
}
} catch (err) {
// If initialization fails, ignore and allow fallback enhancer
console.warn('DataTables init failed for table', this, err);
}
});
}
@@ -139,35 +562,22 @@
initDropdowns();
initSearch();
initPageInteractions();
initDataTables();
enhanceTables();
makeResponsive();
initChartSafeguard();
// Set initial sidebar width variable and keep it updated
updateSidebarWidth();
// Set initial nav offset and keep it updated on resize
updateSidebarNavOffset();
const debouncedUpdateNavOffset = debounce(function() {
updateSidebarNavOffset();
updateSidebarWidth();
}, 120);
window.addEventListener('resize', debouncedUpdateNavOffset);
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
themeBtn.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-theme') || 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem(STORAGE_KEYS.themeMode, newTheme);
themeBtn.setAttribute('aria-pressed', newTheme === 'dark');
});
}
/**
* Initialize page on DOM ready
*/
function init() {
initSidebarToggle();
initThemeToggle();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {

View File

@@ -0,0 +1,91 @@
{#
/**
* Copyright (C) 2026 OTS Signs
*
* About page for OTS Signs.
*/
#}
{% extends "non-authed.twig" %}
{% block title %}{{ "About"|trans }} | {% endblock %}
{% block style %}
<style type="text/css">
.about-container {
padding: 24px 30px 30px;
margin: 10px auto 20px;
background-color: #fff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
max-width: 720px;
}
.about-links {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
}
.about-links a {
font-size: 14px;
}
.about-meta {
margin-top: 16px;
font-size: 14px;
color: #6c757d;
}
.about-disclaimer {
margin-top: 16px;
font-size: 14px;
}
</style>
{% endblock %}
{% block header %}{% endblock %}
{% block contentClass %}{% endblock %}
{% block content %}
<a class="btn btn-icon btn-info" href="{{ url_for("home") }}" title="{% trans "Home" %}"><i class="fa fa-home"></i></a>
<div class="about-container">
<h1>
{% trans "About" %}
<a href="https://ots-signs.com" target="_blank" rel="noopener noreferrer">OTS Signs</a>
</h1>
<p>
{% trans "An" %}
<a href="https://oribi-tech.com" target="_blank" rel="noopener noreferrer">Oribi Technology Services</a>
{% trans "product." %}
</p>
<p class="text-muted">{% trans "OTS Signs provides a compact, focused admin UI and proxy for Xibo CMS" %}</p>
{% set appVersion = version|default("dev") %}
{% set appEnvironment = appEnvironment|default(environment|default("local")) %}
{% set commitSha = revision|default("") %}
<div class="about-meta">
<div>{% trans "Version" %}: {{ appVersion }}</div>
<div>{% trans "Environment" %}: {{ appEnvironment }}</div>
{% if commitSha %}
<div>{% trans "Commit" %}: {{ commitSha|slice(0, 7) }}</div>
{% endif %}
</div>
<div class="about-links">
<a href="/privacy">{% trans "Privacy Policy" %}</a>
<a href="/terms">{% trans "Terms of Service" %}</a>
<a href="/open-source-licenses">{% trans "Open Source Licenses" %}</a>
</div>
<div class="about-disclaimer">
<strong>{% trans "Disclaimer:" %}</strong>
{% trans "OTS Signs is an independent product developed by Oribi Technology Services. It is not affiliated with or endorsed by the Xibo project or its maintainers. Xibo is a trademark of Xibo Digital Signage Ltd. Use of Xibo APIs is subject to their terms and conditions." %}
<div class="mt-2">
<a href="https://github.com/xibosignage/xibo" target="_blank" rel="noopener noreferrer">{% trans "Xibo CMS on GitHub" %}</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{#
/**
* Copyright (C) 2026 OTS Signs
*
* About dialog content for OTS Signs.
*/
#}
{% extends "form-base.twig" %}
{% block formTitle %}{% trans "About" %}{% endblock %}
{% block formButtons %}
{% trans "Close" %}, XiboDialogClose()
{% endblock %}
{% block formHtml %}
<div class="about-container">
<h2>
{% trans "About" %}
<a href="https://ots-signs.com" target="_blank" rel="noopener noreferrer">OTS Signs</a>
</h2>
<p>
{% trans "An" %}
<a href="https://oribi-tech.com" target="_blank" rel="noopener noreferrer">Oribi Technology Services</a>
{% trans "product." %}
</p>
<p class="text-muted">{% trans "OTS Signs provides a compact, focused interface and hosting for Xibo CMS" %}</p>
{% set appVersion = version|default("dev") %}
{% set appEnvironment = appEnvironment|default(environment|default("local")) %}
{% set commitSha = revision|default("") %}
<div class="about-meta">
<div>{% trans "Version" %}: {{ appVersion }}</div>
<div>{% trans "Environment" %}: {{ appEnvironment }}</div>
{% if commitSha %}
<div>{% trans "Commit" %}: {{ commitSha|slice(0, 7) }}</div>
{% endif %}
</div>
<div class="about-links">
<a href="/privacy">{% trans "Privacy Policy" %}</a>
<a href="/terms">{% trans "Terms of Service" %}</a>
<a href="/open-source-licenses">{% trans "Open Source Licenses" %}</a>
</div>
<div class="about-disclaimer">
<strong>{% trans "Disclaimer:" %}</strong>
{% trans "OTS Signs is an independent product developed by Oribi Technology Services. It is not affiliated with or endorsed by the Xibo project or its maintainers. Xibo is a trademark of Xibo Digital Signage Ltd. Use of Xibo APIs is subject to their terms and conditions." %}
<div class="mt-2">
<a href="https://github.com/xibosignage/xibo" target="_blank" rel="noopener noreferrer">{% trans "Xibo CMS on GitHub" %}</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{#
Compact-aware notification drawer override
#}
{% if compact is defined and compact %}
<div class="dropdown nav-item item ots-notif-compact">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarNotificationDrawer">
<span class="ots-topbar-icon fa fa-bell" aria-hidden="true"></span>
</a>
<div class="dropdown-menu dropdown-menu-right ots-notif-menu" aria-labelledby="navbarNotificationDrawer">
<div class="dropdown-header">Notifications</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item">No new notifications</div>
</div>
</div>
{% else %}
<li class="dropdown nav-item item">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarNotificationDrawer">
<span class="ots-topbar-icon fa fa-bell" aria-hidden="true"></span>
</a>
<div class="dropdown-menu dropdown-menu-right ots-notif-menu" aria-labelledby="navbarNotificationDrawer">
<div class="dropdown-header">Notifications</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item">No new notifications</div>
</div>
</li>
{% endif %}

View File

@@ -1,116 +1,441 @@
{#
OTS Signage Modern Theme - Sidebar Override
Modern left navigation sidebar with collapsible state and icons
OTS Signage Theme override
Based on Xibo CMS default authed-sidebar.twig (master branch)
Applied OTS sidebar styling
#}
<nav class="ots-sidebar" aria-label="Main navigation">
<div class="sidebar-header">
<a href="{{ baseUrl }}/" class="brand-link">
<span class="brand-icon">🎯</span>
<span class="brand-text">OTS Signs</span>
</a>
<button class="sidebar-close-btn" aria-label="Close sidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="sidebar-content">
<ul class="sidebar-nav">
<li>
<a href="{{ baseUrl }}" class="nav-item {% if pageTitle == 'Dashboard' %}active{% endif %}">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span class="nav-text">Dashboard</span>
<div id="sidebar-wrapper" class="ots-sidebar">
<div class="sidebar-header">
<a class="brand-link" href="{{ url_for("home") }}">
<span class="brand-icon">
<img class="brand-logo" src="{{ theme.uri("img/xibologo.png") }}" alt="{% trans "Logo" %}">
</span>
<span class="brand-text">OTS Signs</span>
</a>
</li>
<li class="nav-section-divider">
<span class="nav-label">Content</span>
</li>
<li><a href="{{ baseUrl }}/library" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="nav-text">Media Library</span>
</a></li>
<li><a href="{{ baseUrl }}/layout" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/>
</svg>
<span class="nav-text">Layouts</span>
</a></li>
<li><a href="{{ baseUrl }}/playlist" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<span class="nav-text">Playlists</span>
</a></li>
<li class="nav-section-divider">
<span class="nav-label">Displays</span>
</li>
<li><a href="{{ baseUrl }}/display" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M12 17v4"/><path d="M8 21h8"/>
</svg>
<span class="nav-text">Displays</span>
</a></li>
<li><a href="{{ baseUrl }}/display-group" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="8" height="8" rx="1" ry="1"/><rect x="14" y="2" width="8" height="8" rx="1" ry="1"/><rect x="2" y="14" width="8" height="8" rx="1" ry="1"/><rect x="14" y="14" width="8" height="8" rx="1" ry="1"/>
</svg>
<span class="nav-text">Display Groups</span>
</a></li>
<li class="nav-section-divider">
<span class="nav-label">Scheduling</span>
</li>
<li><a href="{{ baseUrl }}/schedule" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><path d="M16 2v4"/><path d="M8 2v4"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="nav-text">Schedules</span>
</a></li>
<li><a href="{{ baseUrl }}/dayparting" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span class="nav-text">Day Parting</span>
</a></li>
<li class="nav-section-divider">
<span class="nav-label">Administration</span>
</li>
<li><a href="{{ baseUrl }}/user" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
<span class="nav-text">Users</span>
</a></li>
<li><a href="{{ baseUrl }}/user-group" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="nav-text">User Groups</span>
</a></li>
<li><a href="{{ baseUrl }}/settings" class="nav-item">
<svg class="nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6M4.22 4.22l4.24 4.24m2.12 2.12l4.24 4.24M1 12h6m6 0h6m-16.78 7.78l4.24-4.24m2.12-2.12l4.24-4.24"/>
</svg>
<span class="nav-text">Settings</span>
</a></li>
</ul>
</div>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="user-avatar user-avatar-lg">{{ user.username|first|upper }}</div>
<div class="user-details">
<div class="user-name">{{ user.username }}</div>
<div class="user-role text-xs">Administrator</div>
</div>
<button class="sidebar-expand-btn" type="button" aria-label="{% trans "Expand sidebar" %}">
<i class="fa fa-chevron-right" aria-hidden="true"></i>
</button>
<button class="sidebar-collapse-btn sidebar-collapse-btn-visible" type="button" aria-label="{% trans "Collapse sidebar" %}">
<i class="fa fa-chevron-left" aria-hidden="true"></i>
</button>
</div>
</div>
</nav>
<div class="sidebar-content">
<ul class="sidebar ots-sidebar-nav">
<li class="sidebar-list">
<a href="{{ url_for("home") }}">
<span class="ots-nav-icon fa fa-home" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Dashboard" %}</span>
</a>
</li>
{% set scheduleCount = currentUser.featureEnabledCount(["schedule.view", "daypart.view"]) %}
{% if scheduleCount > 0 %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="scheduling">
<span class="ots-nav-icon fa fa-calendar" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Scheduling" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("daypart.view") %}
<li class="sidebar-list">
<a href="{{ url_for("daypart.view") }}">
<span class="ots-nav-icon fa fa-clock-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Dayparts" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
<li class="sidebar-list">
<a href="{{ url_for("schedule.view") }}">
<span class="ots-nav-icon fa fa-calendar-check-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Schedules" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["library.view", "playlist.view", "dataset.view", "menuBoard.view"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="media">
<span class="ots-nav-icon fa fa-picture-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Media" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("library.view") %}
<li class="sidebar-list">
<a href="{{ url_for("library.view") }}">
<span class="ots-nav-icon fa fa-image" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Library" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("playlist.view") %}
<li class="sidebar-list">
<a href="{{ url_for("playlist.view") }}">
<span class="ots-nav-icon fa fa-list" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Playlists" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("dataset.view") %}
<li class="sidebar-list">
<a href="{{ url_for("dataset.view") }}">
<span class="ots-nav-icon fa fa-database" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "DataSets" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("menuBoard.view") %}
<li class="sidebar-list">
<a href="{{ url_for("menuBoard.view") }}">
<span class="ots-nav-icon fa fa-cutlery" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Menu Boards" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["campaign.view", "layout.view", "template.view", "resolution.view"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="design">
<span class="ots-nav-icon fa fa-paint-brush" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Design" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("campaign.view") %}
<li class="sidebar-list">
<a href="{{ url_for("campaign.view") }}">
<span class="ots-nav-icon fa fa-bullhorn" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Campaigns" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
<li class="sidebar-list">
<a href="{{ url_for("layout.view") }}">
<span class="ots-nav-icon fa fa-columns" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Layouts" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("template.view") %}
<li class="sidebar-list">
<a href="{{ url_for("template.view") }}">
<span class="ots-nav-icon fa fa-clone" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Templates" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("resolution.view") %}
<li class="sidebar-list">
<a href="{{ url_for("resolution.view") }}">
<span class="ots-nav-icon fa fa-expand" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Resolutions" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["displays.view", "displaygroup.view", "displayprofile.view", "playersoftware.view", "command.view", "display.syncView"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="displays">
<span class="ots-nav-icon fa fa-desktop" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Displays" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("displays.view") %}
<li class="sidebar-list">
<a href="{{ url_for("display.view") }}">
<span class="ots-nav-icon fa fa-desktop" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "All Displays" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
<li class="sidebar-list">
<a href="{{ url_for("displaygroup.view") }}">
<span class="ots-nav-icon fa fa-object-group" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Screen Groups" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("display.syncView") %}
<li class="sidebar-list">
<a href="{{ url_for("syncgroup.view") }}">
<span class="ots-nav-icon fa fa-link" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Sync Groups" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
<li class="sidebar-list">
<a href="{{ url_for("displayprofile.view") }}">
<span class="ots-nav-icon fa fa-cog" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Display Settings" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("playersoftware.view") %}
<li class="sidebar-list">
<a href="{{ url_for("playersoftware.view") }}">
<span class="ots-nav-icon fa fa-download" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Player Versions" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("command.view") %}
<li class="sidebar-list">
<a href="{{ url_for("command.view") }}">
<span class="ots-nav-icon fa fa-terminal" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Commands" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% if currentUser.featureEnabled("users.view") and (currentUser.isGroupAdmin() or currentUser.isSuperAdmin()) %}
{% set userMenuViewable = true %}
{% else %}
{% set userMenuViewable = false %}
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["usergroup.view", "module.view", "transition.view", "task.view", "tag.view", "font.view"]) %}
{% if countViewable > 0 or userMenuViewable %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="settings">
<span class="ots-nav-icon fa fa-cog" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Settings" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if userMenuViewable %}
<li class="sidebar-list">
<a href="{{ url_for("user.view") }}">
<span class="ots-nav-icon fa fa-user" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Users" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("usergroup.view") %}
<li class="sidebar-list">
<a href="{{ url_for("group.view") }}">
<span class="ots-nav-icon fa fa-users" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "User Groups" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.isSuperAdmin() %}
<li class="sidebar-list">
<a href="{{ url_for("admin.view") }}">
<span class="ots-nav-icon fa fa-sliders" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Settings" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.isSuperAdmin() %}
<li class="sidebar-list">
<a href="{{ url_for("application.view") }}">
<span class="ots-nav-icon fa fa-puzzle-piece" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Applications" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("module.view") %}
<li class="sidebar-list">
<a href="{{ url_for("module.view") }}">
<span class="ots-nav-icon fa fa-cubes" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Modules" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("transition.view") %}
<li class="sidebar-list">
<a href="{{ url_for("transition.view") }}">
<span class="ots-nav-icon fa fa-random" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Transitions" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("task.view") %}
<li class="sidebar-list">
<a href="{{ url_for("task.view") }}">
<span class="ots-nav-icon fa fa-tasks" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Tasks" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("tag.view") %}
<li class="sidebar-list">
<a href="{{ url_for("tag.view") }}">
<span class="ots-nav-icon fa fa-tags" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Tags" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.isSuperAdmin() %}
<li class="sidebar-list">
<a href="{{ url_for("folders.view") }}">
<span class="ots-nav-icon fa fa-folder-open" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Folders" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("font.view") %}
<li class="sidebar-list">
<a href="{{ url_for("font.view") }}">
<span class="ots-nav-icon fa fa-font" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Fonts" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["report.view", "report.scheduling", "report.saving"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="reporting">
<span class="ots-nav-icon fa fa-bar-chart" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Reporting" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("report.view") %}
<li class="sidebar-list">
<a href="{{ url_for("report.view") }}">
<span class="ots-nav-icon fa fa-file-text-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "All Reports" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("report.scheduling") %}
<li class="sidebar-list">
<a href="{{ url_for("reportschedule.view") }}">
<span class="ots-nav-icon fa fa-calendar" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Report Schedules" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("report.saving") %}
<li class="sidebar-list">
<a href="{{ url_for("savedreport.view") }}">
<span class="ots-nav-icon fa fa-floppy-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Saved Reports" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["log.view", "sessions.view", "auditlog.view", "fault.view"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="advanced">
<span class="ots-nav-icon fa fa-shield" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Advanced" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("log.view") %}
<li class="sidebar-list">
<a href="{{ url_for("log.view") }}">
<span class="ots-nav-icon fa fa-list-alt" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Log" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("sessions.view") %}
<li class="sidebar-list">
<a href="{{ url_for("sessions.view") }}">
<span class="ots-nav-icon fa fa-user-secret" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Sessions" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("auditlog.view") %}
<li class="sidebar-list">
<a href="{{ url_for("auditlog.view") }}">
<span class="ots-nav-icon fa fa-clipboard" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Audit Trail" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("fault.view") %}
<li class="sidebar-list">
<a href="{{ url_for("fault.view") }}">
<span class="ots-nav-icon fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Report Fault" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["developer.edit"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group is-open">
<a class="sidebar-group-toggle" href="#" aria-expanded="true" data-group="developer">
<span class="ots-nav-icon fa fa-code" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Developer" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("developer.edit") %}
<li class="sidebar-list">
<a href="{{ url_for("developer.templates.view") }}">
<span class="ots-nav-icon fa fa-code" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Module Templates" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
</ul>
</div>
</div>

View File

@@ -0,0 +1,6 @@
{#
OTS Signage Theme override
Optional include rendered in authed.twig (top right navbar)
Minimal, low-risk addition for verification
#}
{# OTS topbar badge removed #}

View File

@@ -0,0 +1,472 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
<ul class="nav navbar-nav ots-topbar">
<li class="nav-item">
<a class="nav-link" href="{{ url_for("home") }}">
<span class="ots-topbar-icon fa fa-home" aria-hidden="true"></span>
{% trans "Dashboard" %}
</a>
</li>
{% set countViewable = currentUser.featureEnabledCount(["schedule.view", "daypart.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-calendar" aria-hidden="true"></span>
{% trans "Schedule" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("schedule.view") }}">
<span class="ots-topbar-icon fa fa-calendar" aria-hidden="true"></span>
{% trans "Schedule" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("daypart.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("daypart.view") }}">
<span class="ots-topbar-icon fa fa-clock" aria-hidden="true"></span>
{% trans "Dayparting" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["campaign.view", "layout.view", "template.view", "resolution.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-paint-brush" aria-hidden="true"></span>
{% trans "Design" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("campaign.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("campaign.view") }}">
<span class="ots-topbar-icon fa fa-bullhorn" aria-hidden="true"></span>
{% trans "Campaigns" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("layout.view") }}">
<span class="ots-topbar-icon fa fa-columns" aria-hidden="true"></span>
{% trans "Layouts" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("template.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("template.view") }}">
<span class="ots-topbar-icon fa fa-clone" aria-hidden="true"></span>
{% trans "Templates" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("resolution.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("resolution.view") }}">
<span class="ots-topbar-icon fa fa-expand" aria-hidden="true"></span>
{% trans "Resolutions" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["library.view", "playlist.view", "dataset.view", "menuBoard.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-folder-open" aria-hidden="true"></span>
{% trans "Library" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("playlist.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("playlist.view") }}">
<span class="ots-topbar-icon fa fa-list" aria-hidden="true"></span>
{% trans "Playlists" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("library.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("library.view") }}">
<span class="ots-topbar-icon fa fa-photo" aria-hidden="true"></span>
{% trans "Media" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("dataset.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("dataset.view") }}">
<span class="ots-topbar-icon fa fa-database" aria-hidden="true"></span>
{% trans "DataSets" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("menuBoard.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("menuBoard.view") }}">
<span class="ots-topbar-icon fa fa-th-large" aria-hidden="true"></span>
{% trans "Menu Boards" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["displays.view", "displaygroup.view", "displayprofile.view", "playersoftware.view", "command.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-desktop" aria-hidden="true"></span>
{% trans "Displays" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("displays.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("display.view") }}">
<span class="ots-topbar-icon fa fa-desktop" aria-hidden="true"></span>
{% trans "Displays" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("displaygroup.view") }}">
<span class="ots-topbar-icon fa fa-object-group" aria-hidden="true"></span>
{% trans "Display Groups" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("display.syncView") %}
<a class="{{ groupElementClass }}" href="{{ url_for("syncgroup.view") }}">
<span class="ots-topbar-icon fa fa-link" aria-hidden="true"></span>
{% trans "Sync Groups" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("displayprofile.view") }}">
<span class="ots-topbar-icon fa fa-sliders" aria-hidden="true"></span>
{% trans "Display Settings" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("playersoftware.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("playersoftware.view") }}">
<span class="ots-topbar-icon fa fa-download" aria-hidden="true"></span>
{% trans "Player Versions" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("command.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("command.view") }}">
<span class="ots-topbar-icon fa fa-terminal" aria-hidden="true"></span>
{% trans "Commands" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% if currentUser.featureEnabled("users.view") and (currentUser.isGroupAdmin() or currentUser.isSuperAdmin()) %}
{% set userMenuViewable = true %}
{% else %}
{% set userMenuViewable = false %}
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["usergroup.view", "module.view", "transition.view", "task.view"]) %}
{% set groupElementClass = (countViewable > 1 or (countViewable == 1 and userMenuViewable)) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 or userMenuViewable %}
{% if countViewable > 1 or (countViewable == 1 and userMenuViewable) %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-cog" aria-hidden="true"></span>
{% trans "Administration" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% endif %}
{% if userMenuViewable %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("user.view") }}">
<span class="ots-topbar-icon fa fa-users" aria-hidden="true"></span>
{% trans "Users" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("usergroup.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("group.view") }}">
<span class="ots-topbar-icon fa fa-users-cog" aria-hidden="true"></span>
{% trans "User Groups" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.isSuperAdmin() %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("admin.view") }}">
<span class="ots-topbar-icon fa fa-wrench" aria-hidden="true"></span>
{% trans "Settings" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.isSuperAdmin() %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("application.view") }}">
<span class="ots-topbar-icon fa fa-th" aria-hidden="true"></span>
{% trans "Applications" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("module.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("module.view") }}">
<span class="ots-topbar-icon fa fa-puzzle-piece" aria-hidden="true"></span>
{% trans "Modules" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("transition.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("transition.view") }}">
<span class="ots-topbar-icon fa fa-exchange" aria-hidden="true"></span>
{% trans "Transitions" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("task.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("task.view") }}">
<span class="ots-topbar-icon fa fa-tasks" aria-hidden="true"></span>
{% trans "Tasks" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("tag.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("tag.view") }}">
<span class="ots-topbar-icon fa fa-tags" aria-hidden="true"></span>
{% trans "Tags" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.isSuperAdmin() %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("folders.view") }}">
<span class="ots-topbar-icon fa fa-folder" aria-hidden="true"></span>
{% trans "Folders" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("font.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("font.view") }}">
<span class="ots-topbar-icon fa fa-font" aria-hidden="true"></span>
{% trans "Fonts" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if countViewable > 1 or (countViewable == 1 and userMenuViewable) %}
</div>
{% endif %}
{% if countViewable > 1 or (countViewable == 1 and userMenuViewable) %}
</li>
{% endif %}
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["report.view", "report.scheduling", "report.saving"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-chart-bar" aria-hidden="true"></span>
{% trans "Reporting" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("report.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("report.view") }}">
<span class="ots-topbar-icon fa fa-file-alt" aria-hidden="true"></span>
{% trans "All Reports" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("report.scheduling") %}
<a class="{{ groupElementClass }}" href="{{ url_for("reportschedule.view") }}">
<span class="ots-topbar-icon fa fa-calendar-alt" aria-hidden="true"></span>
{% trans "Report Schedules" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("report.saving") %}
<a class="{{ groupElementClass }}" href="{{ url_for("savedreport.view") }}">
<span class="ots-topbar-icon fa fa-save" aria-hidden="true"></span>
{% trans "Saved Reports" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["log.view", "sessions.view", "auditlog.view", "fault.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-shield-alt" aria-hidden="true"></span>
{% trans "Advanced" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("log.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("log.view") }}">
<span class="ots-topbar-icon fa fa-list-alt" aria-hidden="true"></span>
{% trans "Log" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("sessions.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("sessions.view") }}">
<span class="ots-topbar-icon fa fa-history" aria-hidden="true"></span>
{% trans "Sessions" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("auditlog.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("auditlog.view") }}">
<span class="ots-topbar-icon fa fa-clipboard-list" aria-hidden="true"></span>
{% trans "Audit Trail" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("fault.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("fault.view") }}">
<span class="ots-topbar-icon fa fa-exclamation-triangle" aria-hidden="true"></span>
{% trans "Report Fault" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["developer.edit"]) %}
{% if countViewable > 0 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-code" aria-hidden="true"></span>
{% trans "Developer" %} <span class="caret"></span>
</a>
<div class="dropdown-menu">
{% if currentUser.featureEnabled("developer.edit") %}
<a class="dropdown-item" href="{{ url_for("developer.templates.view") }}">
<span class="ots-topbar-icon fa fa-code-branch" aria-hidden="true"></span>
{% trans "Module Templates" %}
</a>
{% endif %}
</div>
</li>
{% endif %}
</ul>

View File

@@ -0,0 +1,50 @@
{#
OTS Signage Theme override
Based on Xibo CMS default authed-user-menu.twig (master branch)
Minimal change: add ots-user-menu class for easy verification
#}
{% if compact is defined and compact %}
<div class="dropdown nav-item item ots-user-menu-compact">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarUserMenu">
<img class="nav-avatar" src="{{ theme.uri("img/avatar.jpg") }}" />
</a>
<div class="dropdown-menu dropdown-menu-right ots-user-menu" aria-labelledby="navbarUserMenu">
{% else %}
<li class="dropdown nav-item item">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarUserMenu">
<img class="nav-avatar" src="{{ theme.uri("img/avatar.jpg") }}" />
</a>
<div class="dropdown-menu dropdown-menu-right ots-user-menu" aria-labelledby="navbarUserMenu">
{% endif %}
<h6 class="dropdown-header">{{ currentUser.userName }}<br/>
<div id="XiboClock">{{ clock }}</div>
</h6>
<div class="dropdown-divider"></div>
<a class="dropdown-item XiboFormButton" href="{{ url_for("user.preferences.form") }}" title="{% trans "Preferences" %}">{% trans "Preferences" %}</a>
{% if currentUser.featureEnabled("user.profile") %}
<a class="dropdown-item XiboFormButton" href="{{ url_for("user.edit.profile.form") }}" title="{% trans "Edit Profile" %}">{% trans "Edit Profile" %}</a>
{% endif %}
<a class="dropdown-item XiboFormButton" href="{{ url_for("user.applications") }}" title="{% trans "View my authenticated applications" %}">{% trans "My Applications" %}</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" id="ots-theme-toggle" href="#" title="Toggle light/dark mode">
<i class="fa fa-moon-o" id="ots-theme-icon" aria-hidden="true"></i>
<span id="ots-theme-label">Dark Mode</span>
</a>
<a class="dropdown-item" id="reshowWelcomeMenuItem" href="{{ url_for("welcome.view") }}">{% trans "Reshow welcome" %}</a>
<a class="dropdown-item XiboFormButton" href="{{ url_for("about") }}" title="{% trans "About the CMS" %}">{% trans "About" %}</a>
{% if not hideLogout %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" title="{% trans "Logout" %}" href="{{ logoutUrl }}">{% trans "Logout" %}</a>
{% endif %}
</div>
{% if compact is defined and compact %}
</div>
{% else %}
</div>
</li>
{% endif %}

View File

@@ -1,83 +1,184 @@
{#
OTS Signage Modern Theme - Authenticated Shell Override
Replaces the header and shell structure with a modern topbar + sidebar layout
/**
* Copyright (C) 2020-2025 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "base.twig" %}
{% block head %}
{{ parent() }}
<link rel="stylesheet" href="{{ baseUrl }}/theme/custom/otssignange/css/override.css" />
<link rel="stylesheet" href="{{ baseUrl }}/theme/custom/otssignange/css/client.css" />
{% block headContent %}
<script nonce="{{ cspNonce }}">
(function(){
try{
var stored = localStorage.getItem('ots-theme-mode');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var mode = stored || (prefersLight ? 'light' : 'light');
if(mode === 'light') document.documentElement.classList.add('ots-light-mode');
else document.documentElement.classList.remove('ots-light-mode');
}catch(e){}
})();
(function(){
// Apply collapsed sidebar state early to prevent header flashing
try {
var collapsed = localStorage.getItem('otsTheme:sidebarCollapsed');
if (collapsed === 'true') {
// diagnostic
try { console.debug && console.debug('otsTheme:sidebarCollapsed early:', collapsed); } catch(e){}
// Add on <html> immediately; body may not be parsed yet
document.documentElement.classList.add('ots-sidebar-collapsed');
if (document.body) document.body.classList.add('ots-sidebar-collapsed');
// Also set the CSS variable used for collapsed width so layout shifts correctly
try {
var v = getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-collapsed-width') || '64px';
document.documentElement.style.setProperty('--ots-sidebar-width', v);
} catch(e){}
try { console.debug && console.debug('applied ots-sidebar-collapsed early'); } catch(e){}
} else {
try { console.debug && console.debug('otsTheme:sidebarCollapsed early: not set'); } catch(e){}
}
} catch (e) {}
})();
</script>
<style nonce="{{ cspNonce }}">html,body{background:#ffffff!important;color:#111111!important}
/* Hide the top header row immediately when sidebar is collapsed to prevent flash */
html.ots-sidebar-collapsed .row.header.header-side,
body.ots-sidebar-collapsed .row.header.header-side,
.ots-sidebar.collapsed ~ .ots-main .row.header.header-side,
.ots-sidebar.collapsed .row.header.header-side { display: none !important; height: 0 !important; margin: 0 !important; padding: 0 !important; }
</style>
{% endblock %}
{% block htmlTag %}
<html lang="en" data-ots-theme="v1" data-mode="dark">
{% endblock %}
{% block content %}
{% set horizontalNav = currentUser.getOptionValue("navigationMenuPosition", theme.getSetting("NAVIGATION_MENU_POSITION", "vertical")) == "horizontal" %}
{% block body %}
<body class="ots-theme ots-dark-mode">
<div class="ots-shell">
{% include "authed-sidebar.twig" %}
{% if not hideNavigation %}
{% set hideNavigation = currentUser.getOptionValue("hideNavigation", "0") %}
{% endif %}
<div class="ots-main">
{% block header %}
<header class="ots-topbar">
<div class="topbar-left">
<button class="btn-ghost topbar-toggle" data-action="toggle-sidebar" aria-label="Toggle sidebar" title="Toggle sidebar">
<svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="topbar-title">
<h1 class="page-title">{{ pageTitle|default('Dashboard') }}</h1>
{% if pageSubtitle is defined %}<p class="page-subtitle">{{ pageSubtitle }}</p>{% endif %}
</div>
</div>
<div {% if hideNavigation == "0" and not horizontalNav and not forceHide %}id="page-wrapper"{% endif %} class="active">
<div class="topbar-right">
<form action="{{ baseUrl }}/search" class="topbar-search" method="get" role="search">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" name="q" placeholder="Search…" aria-label="Search" class="search-input" />
</form>
<div class="topbar-actions">
<button class="topbar-btn" aria-label="Notifications" title="Notifications">
<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
<div class="dropdown user-menu">
<button class="topbar-btn user-btn" aria-label="User menu" aria-expanded="false">
<span class="avatar avatar-sm">{{ user.username|first|upper }}</span>
</button>
<ul class="dropdown-menu dropdown-right" role="menu">
<li><a href="{{ baseUrl }}/profile" role="menuitem">Profile</a></li>
<li><a href="{{ baseUrl }}/logout" role="menuitem">Sign out</a></li>
</ul>
{% if hideNavigation == "0" and not forceHide %}
{% if horizontalNav %}
<nav class="navbar navbar-default navbar-expand-lg">
<a class="navbar-brand xibo-logo-container" href="#">
<img class="xibo-logo" src="{{ theme.uri("img/xibologo.png") }}">
<span class="xibo-logo-text">
<span class="brand-line brand-line-top">OTS</span>
<span class="brand-line brand-line-bottom">Signs</span>
</span>
</a>
<!-- Brand and toggle get grouped for better mobile display -->
<button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbar-collapse-1" aria-controls="navbarNav" aria-expanded="false">
<span class="fa fa-bars"></span>
</button>
<!-- Collect the nav links, forms, and other content for toggling -->
<div class="navbar-collapse collapse justify-content-between" id="navbar-collapse-1">
{% include "authed-topbar.twig" %}
<ul class="nav navbar-nav navbar-right">
{% include "authed-theme-topbar.twig" ignore missing %}
{% if currentUser.featureEnabled("drawer") %}
{% include "authed-notification-drawer.twig" %}
{% endif %}
{% include "authed-user-menu.twig" %}
</ul>
</div><!-- /.navbar-collapse -->
</nav>
{% else %}
<div class="navbar-collapse navbar-collapse-side collapse" id="navbar-collapse-1">
{% include "authed-sidebar.twig" %}
</div>
{% endif %}
{% endif %}
<div id="content-wrapper">
<div class="page-content">
{% if not horizontalNav or hideNavigation == "1" or forceHide %}
<div class="row header header-side">
<div class="col-sm-12">
<div class="meta pull-left xibo-logo-container">
<div class="page"><img class="xibo-logo" src="{{ theme.uri("img/xibologo.png") }}"></div>
</div>
{% if not forceHide %}
{% if not hideNavigation == "1" %}
<button type="button" class="pull-right navbar-toggler navbar-toggler-side" data-toggle="collapse" data-target="#navbar-collapse-1" aria-controls="navbarNav" aria-expanded="false">
<span class="fa fa-bars"></span>
</button>
{% endif %}
<div class="user-actions pull-right">
{% if currentUser.featureEnabled("drawer") %}
<div class="user-notif">
{% include "authed-notification-drawer.twig" with { 'compact': true } %}
</div>
{% endif %}
<div class="user">
{% include "authed-user-menu.twig" with { 'compact': true } %}
</div>
</div>
{% include "authed-theme-topbar.twig" ignore missing %}
{% endif %}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-sm-12">
{% block actionMenu %}{% endblock %}
{% if settings.INSTANCE_SUSPENDED == "partial" %}
<div class="alert alert-warning">{{ "CMS suspended. Displays will show cached content. Please contact your administrator."|trans }}</div>
{% endif %}
{% block pageContent %}{% endblock %}
</div>
</div>
<div class="row">
<div class="col-sm-12">
{% block pageFooter %}{% endblock %}
</div>
</div>
</div>
</div>
</div>
</header>
{% endblock %}
<main class="ots-content">
{% block content %}
<!-- Content inserted here by page templates -->
{% endblock %}
</main>
{% block footer %}
<footer class="ots-footer">
<p class="text-muted text-xs">&copy; {{ currentDate|date('Y') }} {{ app_name }}. Powered by <a href="https://xibosignage.com">Xibo</a>.</p>
</footer>
{% endblock %}
</div>
</div>
{% block scripts %}
{{ parent() }}
<script src="{{ baseUrl }}/theme/custom/otssignange/js/theme.js"></script>
{% endblock %}
</body>
{% set helpLinks = helpService.getLinksForPage(route) %}
{% set faultViewEnabled = currentUser.featureEnabled("fault.view") %}
{# Hide in mobile view (sm/<768px) #}
<div id="help-pane" class="d-none d-md-flex help-pane"
data-help-links="{{ helpLinks|json_encode }}"
data-url-help-landing-page={{ helpService.getLandingPage() }}
data-fault-view-enabled={{faultViewEnabled}}
data-fault-view-url={{ url_for("fault.view") }}
>
<div class="help-pane-container" style="display: none;">
</div>
<div class="help-pane-btn">
<i class="fas fa-question"></i>
</div>
</div>
{% endblock %}
{% block javaScriptTemplates %}
{# File upload templates and scripts #}
{% include "include-file-upload.twig" %}
{% endblock %}

View File

@@ -0,0 +1,186 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Campaigns"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("campaign.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new Campaign" %}" href="{{ url_for("campaign.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Campaigns" %}</h1>
<p class="text-muted">{% trans "Manage your campaigns and ad campaigns." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="campaignView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Campaigns" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set title %}{% trans "Layouts" %}{% endset %}
{% set values = [{id: 0, value: ""}, {id: 2, value: "Yes"}, {id: 1, value: "No"}] %}
{{ inline.dropdown("hasLayouts", "single", title, 0, values, "id", "value") }}
{{ inline.hidden("folderId") }}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title, layoutId) }}
{% if currentUser.featureEnabled('ad.campaign') %}
{% set title %}{% trans "Type" %}{% endset %}
{% set options = [
{ id: null, name: "" },
{ id: "list", name: "Layout list"|trans },
{ id: "ad", name: "Ad Campaign"|trans }
] %}
{{ inline.dropdown("type", "single", title, "both", options, "id", "name", helpText) }}
{% endif %}
{% set title %}{% trans "Cycle Based Playback" %}{% endset %}
{% set enabled %}{% trans "Enabled" %}{% endset %}
{% set disabled %}{% trans "Disabled" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 0, option: disabled},
{ optionid: 1, option: enabled}
] %}
{{ inline.dropdown("cyclePlaybackEnabled", "single", title, "", options, "optionid", "option") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="campaigns" class="table table-striped" data-content-type="campaign" data-content-id-name="campaignId" data-state-preference-name="campaignGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "Name" %}</th>
{% if currentUser.featureEnabled('ad.campaign') %}
<th>{% trans "Type" %}</th>
<th>{% trans "Start Date" %}</th>
<th>{% trans "End Date" %}</th>
{% endif %}
<th>{% trans "# Layouts" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Duration" %}</th>
<th>{% trans "Cycle based Playback" %}</th>
<th>{% trans "Play Count" %}</th>
{% if currentUser.featureEnabled('ad.campaign') %}
<th>{% trans "Target Type" %}</th>
<th>{% trans "Target" %}</th>
<th>{% trans "Plays" %}</th>
<th>{% trans "Spend" %}</th>
<th>{% trans "Impressions" %}</th>
{% endif %}
<th>{% trans "Ref 1" %}</th>
<th>{% trans "Ref 2" %}</th>
<th>{% trans "Ref 3" %}</th>
<th>{% trans "Ref 4" %}</th>
<th>{% trans "Ref 5" %}</th>
<th>{% trans "Created At" %}</th>
<th>{% trans "Modified At" %}</th>
<th>{% trans "Modified By" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables and translations #}
<script type="text/javascript" nonce="{{ cspNonce }}" defer>
{# JS variables #}
var campaignSearchURL = "{{ url_for('campaign.search') }}";
var layoutSearchURL = "{{ url_for('layout.search') }}";
var folderViewEnabled = "{{ currentUser.featureEnabled('folder.view') }}";
var adCampaignEnabled = "{{ currentUser.featureEnabled('ad.campaign') }}";
var taggingEnabled = "{{ currentUser.featureEnabled('tag.tagging') }}";
{# Custom translations #}
var campaignPageTrans = {
list: "{% trans "List" %}",
ad: "{% trans "Ad" %}",
plays: "{% trans "Plays" %}",
budget: "{% trans "Budget" %}",
impressions: "{% trans "Impressions" %}",
};
</script>
{# Add page source code bundle #}
<script src="{{ theme.rootUri() }}dist/pages/campaign-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{% endblock %}

View File

@@ -0,0 +1,162 @@
{#
/**
* Copyright (C) 2020-2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Commands"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("command.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add Command" %}" href="{{ url_for("command.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Commands" %}</h1>
<p class="text-muted">{% trans "Create and manage commands for Displays." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Commands" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('command', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.inputNameGrid('code', title, null, 'useRegexForCode', 'logicalOperatorCode') }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 dashboard-card ots-table-card">
<table id="commands" class="table table-striped" data-state-preference-name="commandGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Available On" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Sharing" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#commands").DataTable({ "language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("command.search") }}",
"data": function(d) {
$.extend(d, $("#commands").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "command", "render": dataTableSpacingPreformatted , responsivePriority: 2},
{ "data": "code" , responsivePriority: 2},
{
"data": "availableOn",
responsivePriority: 3,
"render": function(data, type) {
if (type !== "display")
return data;
var returnData = '';
if (typeof data !== undefined && data != null) {
var arrayOfTags = data.split(',');
returnData += '<div class="permissionsDiv">';
for (var i = 0; i < arrayOfTags.length; i++) {
var name = arrayOfTags[i];
if (name !== '') {
returnData += '<li class="badge ' + ((name === 'lg') ? '' : 'capitalize') + '">' + name.replace("lg", "webOS").replace("sssp", "Tizen") + '</span></li>'
}
}
returnData += '</div>';
}
return returnData;
}
},
{ "data": "description", responsivePriority: 3 },
{
"data": "groupsWithPermissions",
responsivePriority: 3,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#commands_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +0,0 @@
{#
OTS Signage Modern Theme - Dashboard Page Override
Modern dashboard with KPI cards, status panels, and quick actions
#}
{% extends "authed.twig" %}
{% block pageTitle %}Dashboard{% endblock %}
{% block content %}
<div class="ots-theme dashboard-page">
{# KPI Cards Row #}
<section class="kpi-section">
<div class="kpi-card">
<div class="kpi-header">
<h3 class="kpi-label">Displays</h3>
<span class="kpi-icon-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M12 17v4"/><path d="M8 21h8"/>
</svg>
</span>
</div>
<div class="kpi-body">
<div class="kpi-number">1</div>
<div class="kpi-meta">100% Displays Online</div>
</div>
<div class="kpi-footer">
<span class="badge badge-success">1</span>
<span class="text-xs text-muted">Online</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-header">
<h3 class="kpi-label">Schedules</h3>
<span class="kpi-icon-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><path d="M16 2v4"/><path d="M8 2v4"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</span>
</div>
<div class="kpi-body">
<div class="kpi-number">0</div>
<div class="kpi-meta">Scheduled events</div>
</div>
<div class="kpi-footer">
<span class="badge badge-secondary">0</span>
<span class="text-xs text-muted">Upcoming</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-header">
<h3 class="kpi-label">Users</h3>
<span class="kpi-icon-box">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
</span>
</div>
<div class="kpi-body">
<div class="kpi-number">2</div>
<div class="kpi-meta">OTS Signs users</div>
</div>
<div class="kpi-footer">
<span class="badge badge-info">2</span>
<span class="text-xs text-muted">Active</span>
</div>
</div>
</section>
{# Main Panels Row #}
<section class="dashboard-panels">
<article class="panel panel-full">
<div class="panel-header">
<h3>Display Status</h3>
<a href="{{ baseUrl }}/display" class="link-secondary">View all →</a>
</div>
<div class="panel-body">
<div class="empty-state-compact">
<p class="text-muted">You have 1 display configured. Last check-in: just now</p>
<a href="{{ baseUrl }}/display" class="btn btn-outline btn-sm">Manage Displays</a>
</div>
</div>
</article>
<article class="panel panel-full">
<div class="panel-header">
<h3>Upcoming Schedules</h3>
<a href="{{ baseUrl }}/schedule" class="link-secondary">View all →</a>
</div>
<div class="panel-body">
<div class="empty-state-compact">
<p class="text-muted">No schedules found. Create a schedule to get started.</p>
<a href="{{ baseUrl }}/schedule/add" class="btn btn-outline btn-sm">Create Schedule</a>
</div>
</div>
</article>
</section>
{# Quick Actions Section #}
<section class="quick-actions-grid">
<h3 class="section-title">Quick Actions</h3>
<div class="action-cards">
<a href="{{ baseUrl }}/schedule/add" class="action-card">
<div class="action-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><path d="M16 2v4"/><path d="M8 2v4"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</div>
<span class="action-label">Create Schedule</span>
</a>
<a href="{{ baseUrl }}/display" class="action-card">
<div class="action-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M12 17v4"/><path d="M8 21h8"/>
</svg>
</div>
<span class="action-label">Manage Displays</span>
</a>
<a href="{{ baseUrl }}/user/add" class="action-card">
<div class="action-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
</div>
<span class="action-label">Add User</span>
</a>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,599 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% import "forms.twig" as forms %}
{% block title %}{{ "DataSets"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("dataset.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new DataSet" %}" href="{{ url_for("dataSet.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "DataSets" %}</h1>
<p class="text-muted">{% trans "Manage structured data sources." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="dataSetView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter DataSets" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-up"></i>
</button>
</div>
<div class="ots-filter-content" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline" onsubmit="return false">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('dataSet', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{% set helpText %}{% trans "Show items which match the provided code" %}{% endset %}
{{ inline.input("code", title, "", helpText) }}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="datasets" class="table table-striped" data-state-preference-name="dataSetGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Remote?" %}</th>
<th>{% trans "Real time?" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Last Sync" %}</th>
<th>{% trans "Data Last Modified" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#datasets").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 0, "asc"]],
ajax: {
"url": "{{ url_for("dataSet.search") }}",
"data": function(d) {
$.extend(d, $("#datasets").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "dataSetId", responsivePriority: 2 },
{ "data": "dataSet", "render": dataTableSpacingPreformatted, responsivePriority: 2 },
{ "data": "description", responsivePriority: 4 },
{ "data": "code", responsivePriority: 3 },
{
"data": "isRemote",
responsivePriority: 3,
"render": dataTableTickCrossColumn
},
{
data: 'isRealTime',
responsivePriority: 3,
render: dataTableTickCrossColumn,
},
{ "data": "owner", responsivePriority: 3 },
{
"data": "groupsWithPermissions",
responsivePriority: 3,
"render": dataTableCreatePermissions
},
{
"data": "lastSync",
responsivePriority: 4,
"render": dataTableDateFromUnix
},
{
"data": "lastDataEdit",
responsivePriority: 4,
"render": dataTableDateFromUnix
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', function(e, settings) {
dataTableDraw(e, settings);
// Upload form
$(".dataSetImportForm").click(function(e) {
e.preventDefault();
var template = Handlebars.compile($("#template-dataset-upload").html());
var data = table.row($(this).closest("tr")).data();
var columns = [];
var i = 1;
$.each(data.columns, function (index, element) {
if (element.dataSetColumnTypeId === 1) {
element.index = i;
columns.push(element);
i++;
}
});
// Handle bars and open a dialog
bootbox.dialog({
message: template({
trans: {
addFiles: "{% trans "Add CSV Files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}",
processing: "{% trans "Processing..." %}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ libraryUpload.validExt }}",
utf8Message: "{% trans "If the CSV file contains non-ASCII characters please ensure the file is UTF-8 encoded" %}"
},
columns: columns
}),
title: "{% trans "CSV Import" %}",
size: 'large',
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function() {
table.ajax.reload();
XiboDialogClose();
}
}
}
}).on('shown.bs.modal', function() {
// Configure the upload form
var url = "{{ url_for("dataSet.import", {id: ':id'}) }}".replace(":id", data.dataSetId);
var form = $(this).find("form");
var refreshSessionInterval;
// Initialize the jQuery File Upload widget:
form.fileupload({
url: url,
disableImageResize: true
});
// Upload server status check for browsers with CORS support:
if ($.support.cors) {
$.ajax({
url: url,
type: 'HEAD'
}).fail(function () {
$('<span class="alert alert-error"/>')
.text('Upload server currently unavailable - ' + new Date())
.appendTo(form);
});
}
// Enable iframe cross-domain access via redirect option:
form.fileupload(
'option',
'redirect',
window.location.href.replace(
/\/[^\/]*$/,
'/cors/result.html?%s'
)
);
form.bind('fileuploadsubmit', function (e, data) {
var inputs = data.context.find(':input');
if (inputs.filter('[required][value=""]').first().focus().length) {
return false;
}
data.formData = inputs.serializeArray().concat(form.serializeArray());
inputs.filter("input").prop("disabled", true);
}).bind('fileuploadstart', function (e, data) {
// Show progress data
form.find('.fileupload-progress .progress-extended').show();
form.find('.fileupload-progress .progress-end').hide();
if (form.fileupload("active") <= 0)
refreshSessionInterval = setInterval("XiboPing('" + pingUrl + "?refreshSession=true')", 1000 * 60 * 3);
return true;
}).bind('fileuploaddone', function (e, data) {
if (refreshSessionInterval != null && form.fileupload("active") <= 0)
clearInterval(refreshSessionInterval);
}).bind('fileuploadprogressall', function (e, data) {
// Hide progress data and show processing
if(data.total > 0 && data.loaded == data.total) {
form.find('.fileupload-progress .progress-extended').hide();
form.find('.fileupload-progress .progress-end').show();
}
}).bind('fileuploadadded fileuploadcompleted fileuploadfinished', function (e, data) {
// Get uploaded and downloaded files and toggle Done button
var filesToUploadCount = form.find('tr.template-upload').length;
var $button = form.parents('.modal:first').find('button.btn-bb-main');
if(filesToUploadCount == 0) {
$button.removeAttr('disabled');
} else {
$button.attr('disabled', 'disabled');
}
});
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#datasets_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
function dataSetFormOpen(dialog) {
// Bind the remote dataset test button
$(dialog).find("#dataSetRemoteTestButton").on('click', function() {
var $form = $(dialog).find("form");
XiboRemoteRequest("{{ url_for("dataSet.test.remote") }}", $form.serializeObject(), function(response) {
if (!response.success || !$.trim(response.data.entries)) {
response.data = response.message;
}
$("#datasetRemoteTestRequestResult").html('<pre style="height: 300px; overflow: scroll">' + JSON.stringify(response.data, null, 3) + '</pre>');
});
});
// Set up some dependencies between the isRemote checkbox and the tabs related to remote datasets
onRemoteFieldChanged(dialog);
// show data source dropdown if real time is checked
onIsRealTimeFieldChanged(dialog);
$(dialog).find("#isRemote").on('change', function() {
onRemoteFieldChanged(dialog);
});
$(dialog).find("#isRealTime").on('change', function() {
onIsRealTimeFieldChanged(dialog);
});
// Auth field
onAuthenticationFieldChanged(dialog);
$(dialog).find("#authentication").on('change', function() {
onAuthenticationFieldChanged(dialog);
});
// remote DataSet source
onSourceFieldChanged(dialog);
$(dialog).find('#sourceId').on('change', function() {
onSourceFieldChanged(dialog);
});
// Validate form manually because
// uri field depends on isRemote being checked
if (forms != undefined) {
const $form = $(dialog).find('form');
forms.validateForm(
$form, // form
$form.parent(), // container
{
submitHandler: XiboFormSubmit,
rules: {
uri: {
required: function(element) {
return $form.find('#isRemote').is(':checked')
},
},
},
},
);
}
}
function onIsRealTimeFieldChanged(dialog) {
var isRealTime = $(dialog).find("#isRealTime").is(":checked");
var dataSourceField = $(dialog).find("#dataSourceField");
var dataConnectorSource = $(dialog).find("#dataConnectorSource");
if (isRealTime) {
// show and enable data connector source
dataSourceField.removeClass("d-none");
dataConnectorSource.prop('disabled', false)
} else {
// hide and disable data connector source
dataSourceField.addClass("d-none");
dataConnectorSource.prop('disabled', true)
}
}
function onRemoteFieldChanged(dialog) {
var isRemote = $(dialog).find("#isRemote").is(":checked");
var $remoteTabs = $(dialog).find(".tabForRemoteDataSet");
if (isRemote) {
$remoteTabs.removeClass("d-none");
} else {
$remoteTabs.addClass("d-none");
}
}
function onAuthenticationFieldChanged(dialog) {
var authentication = $(dialog).find("#authentication").val();
var $authFieldUserName = $(dialog).find(".auth-field-username");
var $authFieldPassword = $(dialog).find(".auth-field-password");
if (authentication === "none") {
$authFieldUserName.addClass("d-none");
$authFieldPassword.addClass("d-none");
} else if (authentication === "bearer") {
$authFieldUserName.addClass("d-none");
$authFieldPassword.removeClass("d-none");
} else {
$authFieldUserName.removeClass("d-none");
$authFieldPassword.removeClass("d-none");
}
}
function onSourceFieldChanged(dialog) {
var sourceId = $(dialog).find('#sourceId').val();
var $jsonSource = $(dialog).find(".json-source-field");
var $csvSource = $(dialog).find(".csv-source-field");
if (sourceId == 1) {
$jsonSource.removeClass('d-none');
$csvSource.addClass('d-none');
} else {
$jsonSource.addClass('d-none');
$csvSource.removeClass('d-none');
}
}
function deleteMultiSelectFormOpen(dialog) {
{% set message = 'Delete any associated data?' %}
var $input = $('<input type=checkbox id="deleteData" name="deleteData"> {{ message|trans|e }} </input>');
$input.on('change', function() {
dialog.data().commitData = {deleteData: $(this).val()};
});
$(dialog).find('.modal-body').append($input);
}
</script>
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-dataset-upload">
<form class="form-horizontal" method="post" enctype="multipart/form-data" data-max-file-size="{{ upload.maxSize }}" data-accept-file-types="/(\.|\/)csv/i">
<div class="row fileupload-buttonbar">
<div class="card p-3 mb-3 bg-light">
{{ upload.maxSizeMessage }} <br>
{{ upload.utf8Message }}
</div>
<div class="col-md-7">
<!-- The fileinput-button span is used to style the file input field as button -->
<span class="btn btn-success fileinput-button">
<i class="fa fa-plus"></i>
<span>{{ trans.addFiles }}</span>
<input type="file" name="files">
</span>
<button type="submit" class="btn btn-primary start">
<i class="fa fa-upload"></i>
<span>{{ trans.startUpload }}</span>
</button>
<button type="reset" class="btn btn-warning cancel">
<i class="fa fa-ban"></i>
<span>{{ trans.cancelUpload }}</span>
</button>
<!-- The loading indicator is shown during file processing -->
<span class="fileupload-loading"></span>
</div>
<!-- The global progress information -->
<div class="col-md-4 fileupload-progress fade">
<!-- The global progress bar -->
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-striped active" role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width:0%;">
<div class="sr-only"></div>
</div>
</div>
<!-- The extended global progress information -->
<div class="progress-extended">&nbsp;</div>
<!-- Processing info container -->
<div class="progress-end" style="display:none;">{{ trans.processing }}</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
{% endverbatim %}
{% set title %}{% trans "Overwrite existing data?" %}{% endset %}
{% set helpText %}{% trans "Erase all content in this DataSet and overwrite it with the new content in this import." %}{% endset %}
{{ forms.checkbox("overwrite", title, "", helpText) }}
{% set title %}{% trans "Ignore first row?" %}{% endset %}
{% set helpText %}{% trans "Ignore the first row? Useful if the CSV has headings." %}{% endset %}
{{ forms.checkbox("ignorefirstrow", title, "", helpText) }}
{% set message %}{% trans "In the fields below please enter the column number in the CSV file that corresponds to the Column Heading listed. This should be done before Adding the file." %}{% endset %}
{{ forms.message(message) }}
{% verbatim %}
{{#each columns}}
<div class="form-group row">
<label class="col-sm-2 control-label" for="csvImport_{{dataSetColumnId}}">{{heading}}</label>
<div class="col-sm-10">
<input class="form-control" name="csvImport_{{dataSetColumnId}}" type="number" id="csvImport_{{dataSetColumnId}}" value="{{ index }}" />
</div>
</div>
{{/each}}
</div>
</div>
<!-- The table listing the files available for upload/download -->
<table role="presentation" class="table table-striped"><tbody class="files"></tbody></table>
</form>
</script>
<!-- The template to display files available for upload -->
<script id="template-dataset-upload" type="text/x-tmpl">
{% for (var i=0, file; file=o.files[i]; i++) { %}
<tr class="template-upload">
<td>
<span class="fileupload-preview"></span>
</td>
<td class="title">
{% if (file.error) { %}
<div><span class="label label-danger">{%=file.error%}</span></div>
{% } %}
{% if (!file.error) { %}
<label for="name[]"><input name="name[]" type="text" id="name" value="" /></label>
{% } %}
</td>
<td>
<p class="size">{%=o.formatFileSize(file.size)%}</p>
{% if (!o.files.error) { %}
<div class="progress">
<div class="progress-bar progress-bar-striped active" role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width:0%;">
<div class="sr-only"></div>
</div>
</div>
</div>
{% } %}
</td>
<td class="btn-group">
{% if (!o.files.error && !i && !o.options.autoUpload) { %}
<button class="btn btn-primary start">
<i class="fa fa-upload"></i>
</button>
{% } %}
{% if (!i) { %}
<button class="btn btn-warning cancel">
<i class="fa fa-ban"></i>
</button>
{% } %}
</td>
</tr>
{% } %}
</script>
<!-- The template to display files available for download -->
<script id="template-dataset-download" type="text/x-tmpl">
{% for (var i=0, file; file=o.files[i]; i++) { %}
<tr class="template-download">
<td>
<p class="name" id="{%=file.storedas%}" status="{% if (file.error) { %}error{% } %}">
{%=file.name%}
</p>
{% if (file.error) { %}
<div><span class="label label-danger">{%=file.error%}</span></div>
{% } %}
</td>
<td>
<span class="size">{%=o.formatFileSize(file.size)%}</span>
</td>
</tr>
{% } %}
</script>
{% endverbatim %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,138 @@
/* High-specificity DataTables contrast overrides
Ensures table body text is readable against dark theme backgrounds.
Light text on dark backgrounds (dark mode).
Dark text on light backgrounds (light mode).
*/
/* FIRST: Light mode rules that check actual background colors (not dependent on body class) */
#datatable-container table.dataTable tbody td,
#datatable-container .dataTables_wrapper table.dataTable tbody td,
.ots-table-card table.dataTable tbody td,
.ots-table-card table.dataTable tbody td * {
color: var(--color-text-primary) !important;
opacity: 1 !important;
}
#datatable-container table.dataTable thead th,
.ots-table-card table.dataTable thead th,
#datatable-container table.dataTable thead th * {
color: var(--color-text-secondary) !important;
opacity: 1 !important;
background-color: var(--color-surface) !important;
}
#datatable-container table.dataTable tbody tr.table-success td,
#datatable-container table.dataTable tbody tr.success td,
#datatable-container table.dataTable tbody tr.selected td,
#datatable-container table.dataTable tbody tr.highlight td {
background-color: rgba(16, 185, 129, 0.1) !important;
color: var(--color-text-primary) !important;
}
#datatable-container table.dataTable tbody td .btn,
#datatable-container table.dataTable tbody td .badge,
#datatable-container table.dataTable tbody td .dropdown-toggle {
color: var(--color-text-primary) !important;
}
#datatable-container table.dataTable tbody tr {
background-color: transparent !important;
}
#datatable-container table.dataTable tbody tr:nth-child(even) {
background-color: var(--color-surface-elevated) !important;
}
#datatable-container table.dataTable tbody tr:hover {
background-color: rgba(37, 99, 235, 0.06) !important;
}
.dataTables_wrapper .dataTables_filter input,
.dataTables_wrapper .dataTables_length select,
.dataTables_wrapper .dataTables_paginate .paginate_button {
color: var(--color-text-primary) !important;
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_filter,
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_paginate {
color: var(--color-text-primary) !important;
}
/* SECOND: Explicit light mode class overrides for when .ots-light-mode is present */
body.ots-light-mode #datatable-container table.dataTable tbody td,
body.ots-light-mode #datatable-container .dataTables_wrapper table.dataTable tbody td,
body.ots-light-mode .ots-table-card table.dataTable tbody td,
body.ots-light-mode .ots-table-card table.dataTable tbody td * {
color: #0f172a !important;
opacity: 1 !important;
}
body.ots-light-mode #datatable-container table.dataTable thead th,
body.ots-light-mode .ots-table-card table.dataTable thead th,
body.ots-light-mode #datatable-container table.dataTable thead th * {
color: #334155 !important;
opacity: 1 !important;
background-color: #f1f5f9 !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr.table-success td,
body.ots-light-mode #datatable-container table.dataTable tbody tr.success td,
body.ots-light-mode #datatable-container table.dataTable tbody tr.selected td,
body.ots-light-mode #datatable-container table.dataTable tbody tr.highlight td {
background-color: rgba(16, 185, 129, 0.1) !important;
color: #0f172a !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody td .btn,
body.ots-light-mode #datatable-container table.dataTable tbody td .badge,
body.ots-light-mode #datatable-container table.dataTable tbody td .dropdown-toggle {
color: #0f172a !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr {
background-color: transparent !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr:nth-child(even) {
background-color: #f1f5f9 !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr:hover {
background-color: rgba(37, 99, 235, 0.06) !important;
}
body.ots-light-mode .dataTables_wrapper .dataTables_filter input,
body.ots-light-mode .dataTables_wrapper .dataTables_length select,
body.ots-light-mode .dataTables_wrapper .dataTables_paginate .paginate_button {
color: #0f172a !important;
background: #ffffff !important;
border-color: #e2e8f0 !important;
}
body.ots-light-mode .dataTables_wrapper .dataTables_info,
body.ots-light-mode .dataTables_wrapper .dataTables_filter,
body.ots-light-mode .dataTables_wrapper .dataTables_length,
body.ots-light-mode .dataTables_wrapper .dataTables_paginate {
color: #0f172a !important;
}
#datatable-container table.dataTable tbody td img,
#datatable-container table.dataTable tbody td svg {
filter: none !important;
}
#datatable-container table.dataTable thead th.sorting:after,
#datatable-container table.dataTable thead th.sorting_asc:after,
#datatable-container table.dataTable thead th.sorting_desc:after {
color: rgba(255,255,255,0.7) !important;
}
.ots-table-card table.dataTable tbody tr td,
.ots-table-card table.dataTable tbody tr td * {
-webkit-text-fill-color: var(--color-text-primary) !important;
}

View File

@@ -0,0 +1,261 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Dayparting"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("daypart.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new Daypart" %}" href="{{ url_for("daypart.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Dayparting" %}</h1>
<p class="text-muted">{% trans "Manage time-based scheduling rules." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Dayparts" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Retired" %}{% endset %}
{% set option1 = "Yes"|trans %}
{% set option2 = "No"|trans %}
{% set values = [{id: 1, value: option1}, {id: 0, value: option2}] %}
{{ inline.dropdown("isRetired", "single", title, 0, values, "id", "value") }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 dashboard-card ots-table-card">
<table id="dayparts" class="table table-striped" data-state-preference-name="daypartGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Start Time" %}</th>
<th>{% trans "End Time" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#dayparts").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("daypart.search") }}",
"data": function(d) {
$.extend(d, $("#dayparts").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "name", "render": dataTableSpacingPreformatted , responsivePriority: 2},
{ "data": "description" },
{ "data": "startTime" },
{ "data": "endTime" },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#dayparts_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function dayPartFormOpen(dialog) {
// Render a set of exceptions
$exceptions = $(dialog).find("#dayPartExceptions");
// Days of the week translations
var daysOfTheWeek = [
{ day: "Mon", title: "{% trans "Monday" %}" },
{ day: "Tue", title: "{% trans "Tuesday" %}" },
{ day: "Wed", title: "{% trans "Wednesday" %}" },
{ day: "Thu", title: "{% trans "Thursday" %}" },
{ day: "Fri", title: "{% trans "Friday" %}" },
{ day: "Sat", title: "{% trans "Saturday" %}" },
{ day: "Sun", title: "{% trans "Sunday" %}" }
];
// Compile the handlebars template
var exceptionsTemplate = Handlebars.compile($("#dayPartExceptionsTemplate").html());
if (dialog.data().extra.exceptions.length == 0) {
// Contexts for template
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: "fa-plus",
exceptionDay: "",
exceptionStart: "",
exceptionEnd: "",
fieldId: 0
};
// Append
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
} else {
// For each of the existing exceptions, create form components
var i = 0;
$.each(dialog.data().extra.exceptions, function (index, field) {
i++;
// call the template
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: ((i == 1) ? "fa-plus" : "fa-minus"),
exceptionDay: field.day,
exceptionStart: field.start,
exceptionEnd: field.end,
fieldId: i
};
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
});
}
// Nabble the resulting buttons
$exceptions.on("click", "button", function (e) {
e.preventDefault();
// find the gylph
if ($(this).find("i").hasClass("fa-plus")) {
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: "fa-minus",
exceptionDay: "",
exceptionStart: "",
exceptionEnd: "",
fieldId: $exceptions.find('.form-group').length + 1
};
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
} else {
// Remove this row
$(this).closest(".form-group").remove();
}
});
// check if we already have this day in exceptions array, if so remove the row with a message.
$exceptions.on("change", "select", function() {
var selectedDays = [];
$('select').not('#' + $(this).attr('id')).each(function(i) {
selectedDays.push($(this).val());
});
if (selectedDays.includes(this.value)) {
toastr.error(translations.dayPartExceptionErrorMessage);
// Remove this row
$(this).closest(".form-group").remove();
}
})
}
// Equals helper for the templates below
Handlebars.registerHelper('eq', function(v1, v2, opts) {
if (v1 === v2) {
return opts.fn(this);
} else {
return opts.inverse(this);
}
});
</script>
{% verbatim %}
<script type="text/x-handlebars-template" id="dayPartExceptionsTemplate">
<div class="form-group row">
<div class="col-3">
<select class="form-control" name="exceptionDays[]" id="exceptionDays_{{fieldId}}">
<option value=""></option>
{{#each daysOfWeek}}
<option value="{{ day }}" {{#eq day ../exceptionDay}}selected{{/eq}}>{{ title }}</option>
{{/each}}
</select>
</div>
<div class="col-3">
{% endverbatim %}
{{ inline.time("exceptionStartTimes[]", "", "{{ exceptionStart }}" ) }}
{% verbatim %}
</div>
<div class="col-3">
{% endverbatim %}
{{ inline.time("exceptionEndTimes[]", "", "{{ exceptionEnd }}" ) }}
{% verbatim %}
</div>
<div class="col-1">
<button class="btn btn-white"><i class="fa {{ buttonGlyph }}"></i></button>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

View File

@@ -0,0 +1,421 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Displays"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("displays.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a Display via user_code displayed on the Player screen" %}" href="{{ url_for("display.addViaCode.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block headContent %}
{# Add page source code bundle ( CSS ) #}
<script nonce="{{ cspNonce }}">
(function(){
try{
var stored = localStorage.getItem('ots-theme-mode');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var mode = stored || (prefersLight ? 'light' : 'light');
if(mode === 'light') document.documentElement.classList.add('ots-light-mode');
else document.documentElement.classList.remove('ots-light-mode');
}catch(e){}
})();
(function(){
// Apply collapsed sidebar state early to prevent header flashing
try {
var collapsed = localStorage.getItem('otsTheme:sidebarCollapsed');
if (collapsed === 'true') {
try { console.debug && console.debug('otsTheme:sidebarCollapsed early (page):', collapsed); } catch(e){}
document.documentElement.classList.add('ots-sidebar-collapsed');
if (document.body) document.body.classList.add('ots-sidebar-collapsed');
try {
var v = getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-collapsed-width') || '64px';
document.documentElement.style.setProperty('--ots-sidebar-width', v);
} catch(e){}
try { console.debug && console.debug('applied ots-sidebar-collapsed early (page)'); } catch(e){}
} else {
try { console.debug && console.debug('otsTheme:sidebarCollapsed early (page): not set'); } catch(e){}
}
} catch (e) {}
})();
</script>
<style nonce="{{ cspNonce }}">html,body{background:#ffffff!important;color:#111111!important}
/* Hide the top header row immediately when sidebar is collapsed to prevent flash */
html.ots-sidebar-collapsed .row.header.header-side,
body.ots-sidebar-collapsed .row.header.header-side,
.ots-sidebar.collapsed ~ .ots-main .row.header.header-side,
.ots-sidebar.collapsed .row.header.header-side { display: none !important; height: 0 !important; margin: 0 !important; padding: 0 !important; }
</style>
<link rel="stylesheet" href="{{ theme.rootUri() }}dist/pages/display-page.bundle.min.css?v={{ version }}&rev={{revision }}">
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Displays" %}</h1>
<p class="text-muted">{% trans "Manage your player fleet and status." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="displayView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Displays" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#filter-general" role="tab" data-toggle="tab">{% trans "General" %}</a></li>
<li class="nav-item"><a class="nav-link" href="#filter-advanced" role="tab" data-toggle="tab">{% trans "Advanced" %}</a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="filter-general">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("displayId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('display', title) }}
{% set title %}{% trans "Status" %}{% endset %}
{% set check %}{% trans "Up to date" %}{% endset %}
{% set cross %}{% trans "Downloading" %}{% endset %}
{% set cloud %}{% trans "Out of date" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: check},
{ optionid: "2", option: cross},
{ optionid: "3", option: cloud}
] %}
{{ inline.dropdown("mediaInventoryStatus", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Logged In?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: yesOption},
{ optionid: "0", option: noOption}
] %}
{{ inline.dropdown("loggedIn", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Authorised?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: yesOption },
{ optionid: "0", option: noOption},
] %}
{{ inline.dropdown("authorised", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "XMR Registered?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 1, option: yesOption},
{ optionid: 0, option: noOption},
] %}
{{ inline.dropdown("xmrRegistered", "single", title, "", options, "optionid", "option") }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
{% set title %}{% trans "Display Group" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("displayGroup.search") },
{ name: "data-filter-options", value: '{"isDisplaySpecific":0}' },
{ name: "data-search-term", value: "displayGroup" },
{ name: "data-id-property", value: "displayGroupId" },
{ name: "data-text-property", value: "displayGroup" },
{ name: "data-initial-key", value: "displayGroupId" },
] %}
{{ inline.dropdown("displayGroupId", "single", title, "", null, "displayGroupId", "displayGroup", helpText, "pagedSelect", "", "", "", attributes) }}
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
{% set title %}{% trans "Display Profile" %}{% endset %}
{{ inline.dropdown("displayProfileId", "single", title, "", [{displayProfileId:null, name:""}]|merge(displayProfiles), "displayProfileId", "name") }}
{% endif %}
{{ inline.hidden("folderId") }}
</div>
<div class="tab-pane" id="filter-advanced">
{% set title %}{% trans "Last Accessed" %}{% endset %}
{{ inline.date("lastAccessed", title) }}
{% set title %}{% trans "Player Type" %}{% endset %}
{% set android %}{% trans "Android" %}{% endset %}
{% set chromeos %}{% trans "ChromeOS" %}{% endset %}
{% set windows %}{% trans "Windows" %}{% endset %}
{% set webos %}{% trans "webOS" %}{% endset %}
{% set sssp %}{% trans "Tizen" %}{% endset %}
{% set linux %}{% trans "Linux" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "android", option: android},
{ optionid: "chromeos", option: chromeos},
{ optionid: "windows", option: windows},
{ optionid: "lg", option: webos},
{ optionid: "sssp", option: sssp},
{ optionid: "linux", option: linux},
] %}
{{ inline.dropdown("clientType", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Player Code" %}{% endset %}
{{ inline.input("clientCode", title) }}
{% set title %}{% trans "Custom ID" %}{% endset %}
{{ inline.input("customId", title) }}
{% set title %}{% trans "Mac Address" %}{% endset %}
{{ inline.input("macAddress", title) }}
{% set title %}{% trans "IP Address" %}{% endset %}
{{ inline.input("clientAddress", title) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set landscape %}{% trans "Landscape" %}{% endset %}
{% set portrait %}{% trans "Portrait" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "landscape", option: landscape},
{ optionid: "portrait", option: portrait}
] %}
{{ inline.dropdown("orientation", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Commercial Licence" %}{% endset %}
{% set licensed %}{% trans "Licensed fully" %}{% endset %}
{% set trial %}{% trans "Trial" %}{% endset %}
{% set notLinceced %}{% trans "Not licenced" %}{% endset %}
{% set notApplicable %}{% trans "Not applicable" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: licensed},
{ optionid: "2", option: trial},
{ optionid: "0", option: notLinceced},
{ optionid: "3", option: notApplicable}
] %}
{{ inline.dropdown("commercialLicence", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Player supported?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 1, option: yesOption},
{ optionid: 0, option: noOption},
] %}
{{ inline.dropdown("isPlayerSupported", "single", title, "", options, "optionid", "option") }}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 dashboard-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none ots-grid-controller">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{{ "Open / Close Folder Search options"|trans }}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div class="map-controller d-none pl-1 ots-grid-controller">
<button type="button" id="map_button" class="btn btn-icon btn-primary" title="{{ "Map"|trans }}"><i class="fa fa-map"></i></button>
</div>
<div class="list-controller d-none pl-1 ots-grid-controller">
<button type="button" id="list_button" class="btn btn-icon btn-primary" title="{{ "List"|trans }}"><i class="fa fa-list"></i></button>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="displays" class="table table-striped" data-content-type="display" data-content-id-name="displayId" data-state-preference-name="displayGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Display" %}</th>
<th>{% trans "Display Type" %}</th>
<th>{% trans "Address" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Authorised?" %}</th>
<th>{% trans "Current Layout" %}</th>
<th>{% trans "Storage Available" %}</th>
<th>{% trans "Storage Total" %}</th>
<th>{% trans "Storage Free %" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Orientation" %}</th>
<th>{% trans "Resolution" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Default Layout" %}</th>
<th>{% trans "Interleave Default" %}</th>
<th>{% trans "Email Alert" %}</th>
<th>{% trans "Logged In" %}</th>
<th>{% trans "Last Accessed" %}</th>
<th>{% trans "Display Profile" %}</th>
<th>{% trans "Version" %}</th>
<th>{% trans "Supported?" %}</th>
<th>{% trans "Device Name" %}</th>
<th>{% trans "IP Address" %}</th>
<th>{% trans "Mac Address" %}</th>
<th>{% trans "Timezone" %}</th>
<th>{% trans "Languages" %}</th>
<th>{% trans "Latitude" %}</th>
<th>{% trans "Longitude" %}</th>
<th>{% trans "Screen shot?" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "CMS Transfer?" %}</th>
<th>{% trans "Bandwidth Limit" %}</th>
<th>{% trans "Last Command" %}</th>
<th>{% trans "XMR Registered" %}</th>
<th>{% trans "Commercial Licence" %}</th>
<th>{% trans "Remote" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Screen Size" %}</th>
<th>{% trans "Is Mobile?" %}</th>
<th>{% trans "Outdoor?" %}</th>
<th>{% trans "Reference 1" %}</th>
<th>{% trans "Reference 2" %}</th>
<th>{% trans "Reference 3" %}</th>
<th>{% trans "Reference 4" %}</th>
<th>{% trans "Reference 5" %}</th>
<th>{% trans "Custom ID" %}</th>
<th>{% trans "Cost Per Play" %}</th>
<th>{% trans "Impressions Per Play" %}</th>
<th>{% trans "Created Date" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Faults?" %}</th>
<th>{% trans "OS Version" %}</th>
<th>{% trans "OS SDK" %}</th>
<th>{% trans "Manufacturer" %}</th>
<th>{% trans "Brand" %}</th>
<th>{% trans "Model" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<!-- Map -->
<div class="row">
<div class="col-sm-12">
<div class="map-legend" style="display:none; position: absolute; z-index: 500; right: 20px; top: 10px;">
<div class="display-map-legend" style="font-size: 12px;">
<div>Logged in</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-green-check.png'/> - Up to date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-yellow-check.png'/> - Out of date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-red-check.png'/> - Downloading/Unknown</div>
</br>
<div>Logged out</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-green-cross.png'/> - Up to date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-yellow-cross.png'/> - Out of date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-red-cross.png'/> - Downloading/Unknown</div>
</div>
</div>
<div id="display-map" class="dashboard-card ots-map-card" data-displays-url="{{ url_for("display.map") }}">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables and translations #}
<script type="text/javascript" nonce="{{ cspNonce }}" defer>
{# JS variables #}
var publicPath = "{{ theme.rootUri() }}";
var displaySearchURL = "{{ url_for('display.search') }}";
var layoutSearchURL = "{{ url_for('layout.search') }}";
var mapConfig = {{ mapConfig| json_encode | raw }};
var playerVersionSupport = "{{playerVersion}}";
var folderViewEnabled = "{{ currentUser.featureEnabled('folder.view') }}";
var taggingEnabled = "{{ currentUser.featureEnabled('tag.tagging') }}";
var showThumbnailColumn = "{{ currentUser.getOptionValue('showThumbnailColumn', 1) }}";
var SHOW_DISPLAY_AS_VNCLINK = "{{ settings.SHOW_DISPLAY_AS_VNCLINK }}";
var SHOW_DISPLAY_AS_VNC_TGT = "{{ settings.SHOW_DISPLAY_AS_VNC_TGT }}";
{# Custom translations #}
var displayPageTrans = {
back: "{% trans "Back" %}",
yes: "{% trans "Yes" %}",
no: "{% trans "No" %}",
daysOfTheWeek: {
monday: "{% trans "Monday" %}",
tuesday: "{% trans "Tuesday" %}",
wednesday: "{% trans "Wednesday" %}",
thursday: "{% trans "Thursday" %}",
friday: "{% trans "Friday" %}",
saturday: "{% trans "Saturday" %}",
sunday: "{% trans "Sunday" %}",
},
playerStatusWindow: "{% trans "Player Status Window" %}",
VNCtoThisDisplay: "{% trans "VNC to this Display" %}",
TeamViewertoThisDisplay: "{% trans "TeamViewer to this Display" %}",
WebkeytoThisDisplay: "{% trans "Webkey to this Display" %}",
};
</script>
{# Add page source code bundle ( JS ) #}
<script src="{{ theme.rootUri() }}dist/leaflet.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/pages/display-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{% endblock %}

View File

@@ -0,0 +1,381 @@
{#
/**
* Copyright (C) 2020-2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Display Groups"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("displaygroup.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add Display Group" %}" href="{{ url_for("displayGroup.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Display Groups" %}</h1>
<p class="text-muted">{% trans "Organize Displays into logical groups." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="displayGroupGridView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Display Groups" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-up"></i>
</button>
</div>
<div class="ots-filter-content" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.input("displayGroupId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('displayGroup', title) }}
{% set title %}{% trans "Display" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("display.search") },
{ name: "data-search-term", value: "display" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "displayId" },
{ name: "data-text-property", value: "display" },
{ name: "data-initial-key", value: "displayId" },
] %}
{% set helpText %}{% trans "Return Display Groups that directly contain the selected Display." %}{% endset %}
{{ inline.dropdown("displayId", "single", title, "", null, "displayId", "display", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Nested Display" %}{% endset %}
{% set helpText %}{% trans "Return Display Groups that contain the selected Display somewhere in the nested Display Group relationship tree." %}{% endset %}
{{ inline.dropdown("nestedDisplayId", "single", title, "", null, "displayId", "display", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Dynamic Criteria" %}{% endset %}
{{ inline.input("dynamicCriteria", title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 dashboard-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none ots-grid-controller">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="displaygroups" class="table table-striped" data-content-type="displayGroup" data-content-id-name="displayGroupId" data-state-preference-name="displayGroupGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Is Dynamic?" %}</th>
<th>{% trans "Criteria" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}
<th>{% trans "Criteria Tags" %}</th>
<th>{% trans "Tags" %}</th>
{% endif %}
<th>{% trans "Sharing" %}</th>
<th>{% trans "Reference 1" %}</th>
<th>{% trans "Reference 2" %}</th>
<th>{% trans "Reference 3" %}</th>
<th>{% trans "Reference 4" %}</th>
<th>{% trans "Reference 5" %}</th>
<th>{% trans "Created Date" %}</th>
<th>{% trans "Modified Date" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var displayGroupTable;
var displayTable;
var criteria;
var criteriaTag;
var useRegexForName;
var exactTags;
var logicalOperator;
var logicalOperatorName;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
displayGroupTable = $("#displaygroups").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
"filter": false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("displayGroup.search") }}",
"data": function(d) {
$.extend(d, $("#displaygroups").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "displayGroupId", responsivePriority: 2},
{ "data": "displayGroup", "render": dataTableSpacingPreformatted, responsivePriority: 2 },
{ "data": "description", responsivePriority: 3 },
{ "data": "isDynamic", "render": dataTableTickCrossColumn, responsivePriority: 3 },
{ "data": "dynamicCriteria", responsivePriority: 4 },
{% if currentUser.featureEnabled("tag.tagging") %}
{ "data": "dynamicCriteriaTags", responsivePriority: 4},
{
"name": "tags",
"sortable": false,
responsivePriority: 3,
"data": dataTableCreateTags
},
{% endif %}
{
"data": "groupsWithPermissions",
visible: false,
responsivePriority: 10,
"render": dataTableCreatePermissions
},
{ "data": "ref1", "visible": false, responsivePriority: 5},
{ "data": "ref2", "visible": false, responsivePriority: 5},
{ "data": "ref3", "visible": false, responsivePriority: 5},
{ "data": "ref4", "visible": false, responsivePriority: 5},
{ "data": "ref5", "visible": false, responsivePriority: 5},
{ "data": "createdDt", "visible": false, responsivePriority: 5 },
{ "data": "modifiedDt", "visible": false, responsivePriority: 5 },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
$("#refreshGrid").click(function () {
displayGroupTable.ajax.reload();
});
displayGroupTable.on('draw', dataTableDraw);
displayGroupTable.on('draw', { form: $("#displaygroups").closest(".XiboGrid").find(".FilterDiv form") }, dataTableCreateTagEvents);
displayGroupTable.on('processing.dt', dataTableProcessing);
dataTableAddButtons(displayGroupTable, $('#displaygroups_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
displayGroupTable.ajax.reload();
});
});
function setDeleteMultiSelectFormOpen(dialog) {
$(dialog).find('.save-button').prop('disabled', false);
var template = Handlebars.compile($('#template-display-group-multi-delete-checkbox').html());
var $input = $(template());
$input.find('input').on('change', function() {
$(dialog).find('.save-button').prop('disabled', !$(this).is(':checked'));
});
$(dialog).find('.modal-body').append($input);
}
function displayGroupAddFormNext() {
// Get form
var $form = $("#displayGroupAddForm");
// Set apply and apply reset data
$form.data("apply", true);
$form.data("applyCallback", 'applyResetCallback');
// Submit form
$form.submit();
}
function applyResetCallback(form) {
// Reset form fields
$(form).find('#displayGroup').val("");
}
function displayGroupFormOpen(dialog) {
displayTable = null;
$(dialog).find("input[name=dynamicCriteria]").on("keyup", _.debounce(function() {
displayGroupQueryDynamicMembers(dialog);
}, 500));
$(dialog).find("input[name=dynamicCriteriaTags], input[name=exactTags], select[name=logicalOperator], select[name=logicalOperatorName]").change(function() {
displayGroupQueryDynamicMembers(dialog);
});
var $form = $('#displayGroupAddForm');
// First time in there
displayGroupQueryDynamicMembers(dialog);
}
function displayGroupQueryDynamicMembers(dialog) {
if ($(dialog).find("input[name=isDynamic]")[0].checked) {
criteria = $(dialog).find("input[name=dynamicCriteria]").val();
criteriaTag = $(dialog).find("input[name=dynamicCriteriaTags]").val();
useRegexForName = $(dialog).find("input[name=useRegexForName]").val();
exactTags = $(dialog).find("input[name=exactTags]").is(':checked');
logicalOperator = $(dialog).find("select[name=logicalOperator]").val();
logicalOperatorName = $(dialog).find("select[name=logicalOperatorName]").val();
if (criteria === "" && criteriaTag === "") {
if (displayTable != null) {
displayTable.destroy();
displayTable = null;
$("#displayGroupDisplays tbody").empty();
}
return;
}
if (displayTable != null) {
displayTable.ajax.reload();
} else {
displayTable = $("#displayGroupDisplays").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("display.search") }}",
"data": function (d) {
$.extend(
d,
{
display: criteria,
tags: criteriaTag,
useRegexForName: useRegexForName,
exactTags: exactTags,
logicalOperator: logicalOperator,
logicalOperatorName: logicalOperatorName
}
);
}
},
"columns": [
{"data": "displayId"},
{"data": "display"},
{"data": dataTableCreateTags},
{
"data": "mediaInventoryStatus",
"render": function (data, type, row) {
if (type != "display")
return data;
var icon = "";
if (data == 1)
icon = "fa-check";
else if (data == 0)
icon = "fa-times";
else
icon = "fa-cloud-download";
return "<span class='fa " + icon + "'></span>";
}
},
{"data": "licensed", "render": dataTableTickCrossColumn}
]
});
displayTable.on('processing.dt', dataTableProcessing);
displayTable.on('draw', { form: $(".displayGroupForm") }, dataTableCreateTagEvents);
}
}
}
</script>
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-display-group-multi-delete-checkbox">
<div class="form-group row">
<div class="offset-sm-2 col-sm-10 mt-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="checkbox-confirmDelete" name="confirmDelete">
<label class="form-check-label" for="checkbox-confirmDelete">
{% endverbatim %}{{ "Are you sure you want to delete?"|trans }}{% verbatim %}
</label>
</div>
<small class="form-text text-muted">{% endverbatim %}{{ "Check to confirm deletion of the selected records."|trans }}{% verbatim %}</small>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

View File

@@ -0,0 +1,168 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Display Setting Profiles"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("displayprofile.add") %}
<button class="btn btn-icon btn-info XiboFormButton" title="{% trans "Add a new Display Settings Profile" %}" href="{{ url_for("displayProfile.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Display Settings" %}</h1>
<p class="text-muted">{% trans "Manage Display settings profiles." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Display Settings" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('displayProfile', title) }}
{% set title %}{% trans "Type" %}{% endset %}
{{ inline.dropdown("type", "single", title, "", [{typeId:null, type:""}]|merge(types), "typeId","type") }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 dashboard-card ots-table-card">
<table id="displayProfiles" class="table table-striped" data-state-preference-name="displayProfileGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Default" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#displayProfiles").DataTable({ "language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("displayProfile.search") }}",
"data": function(d) {
$.extend(d, $("#displayProfiles").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "name", "render": dataTableSpacingPreformatted , responsivePriority: 2},
{ "data": "type" },
{ "data": "isDefault", "render": dataTableTickCrossColumn },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#displayProfiles_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
// Custom submit for display profile form
function displayProfileEditFormSubmit() {
var $form = $("#displayProfileForm");
// Remove temp fields and enable checkbox after submit
$form.submit(function(event) {
event.preventDefault();
// Re-enable checkboxes
$form.find('input[type="checkbox"]').each(function () {
// Enable checkbox
$(this).attr('disabled', false);
});
// Remove temp input fields
$form.find('input.temp-input').each(function () {
$(this).remove();
});
});
// Replace all checkboxes with hidden input fields
$form.find('input[type="checkbox"]').each(function () {
// Get checkbox values
var value = $(this).is(':checked') ? 'on' : 'off';
var id = $(this).attr('id');
// Create hidden input
$('<input type="hidden" class="temp-input">')
.attr('id', id)
.attr('name', id)
.val(value)
.appendTo($(this).parent());
// Disable checkbox so it won't be submitted
$(this).attr('disabled', true);
});
// Submit form
$form.submit();
}
</script>
{% endblock %}

View File

@@ -1,124 +0,0 @@
{#
OTS Signage Modern Theme - Displays Page Override
Two-column layout with folder panel on left, modern display table
#}
{% extends "authed.twig" %}
{% block pageTitle %}Displays{% endblock %}
{% block content %}
<div class="ots-theme two-column-layout">
<aside class="left-panel displays-sidebar">
<div class="panel-header">
<h3>Folders</h3>
<button class="btn-icon-sm" aria-label="Expand/collapse">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="12 3 20 9 12 15 4 9 12 3"/><polyline points="4 15 12 21 20 15"/>
</svg>
</button>
</div>
<div class="folder-tree">
<div class="folder-item active">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">All Items</span>
</div>
<div class="folder-item">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">Root Folder</span>
</div>
<div class="folder-item">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">TEMPLATE_DemoHolder</span>
</div>
<div class="folder-item" style="margin-left: 16px;">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">Hospitality</span>
</div>
<div class="folder-item" style="margin-left: 16px;">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">Retail</span>
</div>
<div class="folder-item">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">OTS Signs Internal</span>
</div>
</div>
</aside>
<main class="content-panel">
<div class="page-header">
<h1>Displays</h1>
<p class="text-muted">Manage and monitor your digital signage displays</p>
</div>
<div class="content-toolbar">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="search" placeholder="Search displays…" class="form-control search-field" />
</div>
<div class="toolbar-actions">
<button class="btn btn-outline btn-sm">Columns</button>
<a href="{{ baseUrl }}/display/add" class="btn btn-primary btn-sm">Add Display</a>
</div>
</div>
<div class="stat-row">
<div class="stat-box">
<div class="stat-label">Total</div>
<div class="stat-value">1</div>
</div>
<div class="stat-box">
<div class="stat-label">Online</div>
<div class="stat-value text-success">1</div>
</div>
<div class="stat-box">
<div class="stat-label">Offline</div>
<div class="stat-value text-danger">0</div>
</div>
</div>
<div class="table-wrapper">
<table class="table table-striped">
<thead>
<tr>
<th style="width: 25%;">Display</th>
<th style="width: 15%;">Status</th>
<th style="width: 20%;">Folder</th>
<th style="width: 15%;">Group</th>
<th style="width: 15%;">Last Check-in</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Test1</strong></td>
<td><span class="badge badge-success">Online</span></td>
<td>TEMPLATE_DemoHolder</td>
<td>Test Screens</td>
<td><span class="text-xs">just now</span></td>
<td>
<button class="btn-icon-sm" aria-label="Actions">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,327 @@
{% macro disabled(name, title, value, helpText, groupClass) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}">{{ title }}</label>
<input readonly class="form-control" value="{{ value }}"></input>
</div>
{% endmacro %}
{% macro hidden(name, value) %}
<input name="{{ name }}" type="hidden" id="{{ name }}" value="{{ value }}" />
{% endmacro %}
{% macro raw(text, groupClass) %}
<div class="{{ groupClass }}">
{{ text|raw }}
</div>
{% endmacro %}
{% macro message(message, groupClass, messageStyleClass) %}
<div class="{% if messageStyleClass %}{{messageStyleClass}}{% endif %} mr-1 {{ groupClass }}">
<span>{{ message }}</span>
</div>
{% endmacro %}
{% macro alert(message, alertType, groupClass) %}
<div class="row">
<div class="mr-3 alert alert-{% if alertType %}{{alertType}}{% else %}primary{% endif %} {{ groupClass }}" role="alert">{{ message }}</div>
</div>
{% endmacro %}
{% macro button(title, type, link, groupClass) %}
<div class="form-group {{ groupClass }}">
{% if type == "link" %}
<a class="btn btn-white xibo-inline-btn mr-1 ml-0" href="{{ link }}">{{ title }}</a>
{% else %}
<button class="btn btn-white xibo-inline-btn mr-1 ml-0" type="{{ type }}">{{ title }}</button>
{% endif %}
</div>
{% endmacro %}
{% macro input(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro inputWithTags(name, title, value, helpText, groupClass, validation, accessKey, exactTag, exactTagTitle, logicalOperatorTitle, autoCompleteEnabled = 1) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
{% if exactTag %}
<div class="input-group input-group-tags-exact">
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" data-role="tagsInputInline" {% if autoCompleteEnabled == 1 %} data-auto-complete-url="{{ url_for('tag.search') }}?allTags=1" {% endif %} {{ validation }} />
<div class="input-group-append input-group-addon">
<div class="input-group-text">
<input title="{{ exactTagTitle }}" type="checkbox" id="{{ exactTag }}" name="{{ exactTag }}">
</div>
<select class="custom-select" id="logicalOperator" name="logicalOperator" title="{{ logicalOperatorTitle }}" style="min-width:auto!important">
<option value="OR" selected>OR</option>
<option value="AND">AND</option>
</select>
</div>
</div>
{% else %}
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" data-role="tagsInputInline" {% if autoCompleteEnabled == 1 %} data-auto-complete-url="{{ url_for('tag.search') }}?allTags=1" {% endif %} {{ validation }} />
{% endif %}
</div>
{% endmacro %}
{% macro number(name, title, value, helpText, groupClass, validation, accessKey, maxNumber, minNumber) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" {% if maxNumber %}max="{{maxNumber}}" {% endif %}{% if minNumber %}min="{{minNumber}}" {% endif %}type="number" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro email(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" type="email" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro password(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" for="{{ name }}" title="{{ helpText }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" type="password" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro checkbox(name, title, value, groupClass, accessKey) %}
<div class="form-group ml-2 mr-3 mb-1 {{ groupClass }}">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="{{ name }}" name="{{ name }}" {% if value == 1 %}checked{% endif %}>
<label class="form-check-label" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
</div>
</div>
{% endmacro %}
{% macro radio(name, id, title, value, helpText, groupClass, accessKey, setValue) %}
<div class="form-group ml-2 mr-3 mb-1 {{ groupClass }}">
<div class="form-check">
<input class="form-check-input" type="radio" id="{{ id }}" name="{{ name }}" value="{{ setValue }}" {% if value == setValue %}checked{% endif %}>
<label class="form-check-label" for="{{ name }}" title="{{ helpText }}" accesskey="{{ accessKey }}">{{ title }}</label>
</div>
</div>
{% endmacro %}
{% macro dropdown(name, type, title, value, options, optionId, optionValue, helpText, groupClass, validation, accessKey, callBack, dataAttributes, optionGroups) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" for="{{ name }}" title="{{ helpText }}" accesskey="{{ accessKey }}">{{ title }}</label>
<select class="form-control" {% if type == "dropdownmulti" %}multiple{% endif %} name="{{ name }}" id="{{ name }}" {{ callBack }}
{% if type == "dropdownmulti" %}
data-allow-clear="true"
data-placeholder--id=null
data-placeholder--value=""
{% endif %}
{% if dataAttributes|length > 0 %}
{% for attribute in dataAttributes %}
{{ attribute.name }}="{{ attribute.value }}"
{% endfor %}
{% endif %}>
{% set hasGroups = optionGroups|length > 0 %}
{% if not hasGroups %}
{% set optionGroups = {label: "General"} %}
{% endif %}
{% for group in optionGroups %}
{% if hasGroups %}
<optgroup label="{{ group.label }}">
{% set tempOptions = attribute(options, group.id) %}
{% else %}
{% set tempOptions = options %}
{% endif %}
{% for option in tempOptions %}
{% set itemOptionId = attribute(option, optionId) %}
{% set itemOptionValue = attribute(option, optionValue) %}
{% if type == "dropdownmulti" %}
{% set selected = (itemOptionId in value) %}
{% else %}
{% set selected = (itemOptionId == value) %}
{% endif %}
<option value="{{ itemOptionId }}" {% if selected %}selected{% endif %}>{{ itemOptionValue }}</option>
{% endfor %}
{% if hasGroups %}
</optgroup>
{% endif %}
{% endfor %}
</select>
</div>
{% endmacro %}
{% macro permissions(name, options) %}
<table class="table table-bordered">
<tr>
<th>{% trans "Group" %}</th>
<th>{% trans "View" %}</th>
<th>{% trans "Edit" %}</th>
<th>{% trans "Delete" %}</th>
</tr>
{% for item in options %}
<tr>
<td>{{ name }}</td>
<td><input type="checkbox" name="{{ name }}" value="{{ value_view }}" {{ value_view_checked }}></td>
<td><input type="checkbox" name="{{ name }}" value="{{ value_edit }}" {{ value_edit_checked }}></td>
<td><input type="checkbox" name="{{ name }}" value="{{ value_del }}" {{ value_del_checked }}></td>
</tr>
{% endfor %}
</table>
{% endmacro %}
{% macro date(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<div class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></div>
<input class="form-control dateControl date" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro dateMonth(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<span class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></span>
<input class="form-control dateControl month" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro dateTime(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ linkedName }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<span class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></span>
<input class="form-control dateControl dateTime" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro time(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1 {% if title == '' %}d-none{% endif %}" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<span class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></span>
<input class="form-control dateControl time" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro switch(name, title, value, labelWidth, switchSize, onText, offText, groupClass, accessKey, disabled) %}
<div class="form-group {{ groupClass }}">
<div class="checkbox">
<input type="checkbox" class="bootstrap-switch-target" id="{{ name }}" name="{{ name }}" accesskey="{{ accessKey }}"
{% if value == 1 %}checked{% endif %}
{% if disabled == 1 %}disabled{% endif %}
data-label-text="{{ title }}"
{% if onText not in [null, undefined, ""] %} data-on-text="{{ onText }}"{% endif %}
{% if offText not in [null, undefined, ""] %} data-off-text="{{ offText }}"{% endif %}
{% if switchSize not in [null, undefined, ""] %}data-size="{{ switchSize }}"{% else %}data-size="small"{% endif %}
{% if labelWidth not in [null, undefined, ""] %} data-label-width="{{ labelWidth }}"{% endif %}
>
</div>
</div>
{% endmacro %}
{% macro color(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control XiboColorPicker" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro inputNameGrid(name, title, groupClass, useRegexName, logicalOperatorName) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="" for="{{ name }}" accesskey="">{{ title }}</label>
<div>
<div class="input-group">
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="">
<div class="input-group-append input-group-addon">
<div class="input-group-text">
<input title="{% trans "Use Regex?" %}" type="checkbox" {% if useRegexName %} id="{{ useRegexName }}" name="{{ useRegexName }}" {% else %} id="useRegexForName" name="useRegexForName"{% endif %}>
</div>
<select class="custom-select" {% if logicalOperatorName %} id="{{ logicalOperatorName }}" name="{{ logicalOperatorName }}" {% else %} id="logicalOperatorName" name="logicalOperatorName"{% endif %}
title="{% trans "When filtering by multiple names, which logical operator should be used?" %}" style="min-width:auto!important">
<option value="OR" selected>OR</option>
<option value="AND">AND</option>
</select>
</div>
</div>
</div>
</div>
{% endmacro %}
{% macro dateRangeFilter(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 d-flex flex-row {{ groupClass }}">
{% set today = now | date_modify('today') | date("Y-m-d H:i:s") %}
<div class="form-group mr-1">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">
{{ title }}
</label>
<div class="d-inline-flex">
<select class="form-control XiboDateRangeFilter" name="{{ name }}" id="{{ name }}">
<option value="" >{% trans "Select a range" %}</option>
<option value="today" selected>{% trans "Today" %}</option>
<option value="yesterday">{% trans "Yesterday" %}</option>
<option value="thisweek">{% trans "This Week" %}</option>
<option value="thismonth">{% trans "This Month" %}</option>
<option value="thisyear">{% trans "This Year" %}</option>
<option value="lastweek">{% trans "Last Week" %}</option>
<option value="lastmonth">{% trans "Last Month" %}</option>
<option value="lastyear">{% trans "Last Year" %}</option>
</select>
</div>
</div>
<div class="form-group hidden mr-1 {{ 'rangeFilterInput_' ~ name }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">
{% trans "From Date" %}
</label>
<div class="input-group">
<div class="input-group-prepend input-group-text date-open-button" role="button">
<i class="fa fa-calendar"></i>
</div>
<input class="form-control dateControl date rangeInput"
type="text" name="fromDt" id="{{ 'fromDt_' ~ name }}"
value="{{ today }}"
/>
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none"
role="button"
>
<i class="fa fa-times"></i>
</span>
</div>
</div>
<div class="form-group hidden {{ 'rangeFilterInput_' ~ name }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">
{% trans "To Date" %}
</label>
<div class="input-group">
<div class="input-group-prepend input-group-text date-open-button" role="button">
<i class="fa fa-calendar"></i>
</div>
<input class="form-control dateControl date rangeInput"
type="text" name="toDt" id="{{ 'toDt_' ~ name }}"
value="{{ today | date_modify('+1 day -1 second') | date("Y-m-d H:i:s") }}"
/>
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none"
role="button"
>
<i class="fa fa-times"></i>
</span>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,536 @@
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Layouts"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("layout.add") %}
<button class="btn btn-success layout-add-button"
title="{% trans "Add a new Layout and jump to the layout editor." %}"
href="{{ url_for("layout.add") }}">
<i class="fa fa-plus-circle" aria-hidden="true"></i>
</button>
<button class="btn btn-icon btn-info" id="layoutUploadForm" title="{% trans "Import a Layout from a ZIP file." %}" href="#"><i class="fa fa-cloud-download" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Layouts" %}</h1>
<p class="text-muted">{% trans "Manage and design your layouts." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-type="layout" data-grid-name="layoutView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Layouts" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab" aria-selected="true"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab" aria-selected="false"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline d-block">
<div class="tab-content">
<div class="tab-pane active" id="general-filter" role="tabpanel">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("campaignId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('layout', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input('codeLike', title) }}
{% if currentUser.featureEnabled("displaygroup.view") %}
{% set title %}{% trans "Display Group" %}{% endset %}
{% set helpText %}{% trans "Show Layouts active on the selected Display / Display Group" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("displayGroup.search") },
{ name: "data-filter-options", value: '{"isDisplaySpecific":-1}' },
{ name: "data-search-term", value: "displayGroup" },
{ name: "data-id-property", value: "displayGroupId" },
{ name: "data-text-property", value: "displayGroup" },
{ name: "data-initial-key", value: "displayGroupId" },
] %}
{{ inline.dropdown("activeDisplayGroupId", "single", title, "", null, "displayGroupId", "displayGroup", helpText, "pagedSelect", "", "", "", attributes) }}
{% endif %}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Owner User Group" %}{% endset %}
{% set helpText %}{% trans "Show items owned by users in the selected User Group." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("group.search") },
{ name: "data-search-term", value: "group" },
{ name: "data-id-property", value: "groupId" },
{ name: "data-text-property", value: "group" },
{ name: "data-initial-key", value: "userGroupId" },
] %}
{{ inline.dropdown("ownerUserGroupId", "single", title, "", null, "groupId", "group", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Landscape"|trans %}
{% set option3 = "Portrait"|trans %}
{% set values = [{id: '', value: option1}, {id: 'landscape', value: option2}, {id: 'portrait', value: option3}] %}
{{ inline.dropdown("orientation", "single", title, '', values, "id", "value") }}
{{ inline.hidden("folderId") }}
</div>
<div class="tab-pane" id="advanced-filter" role="tabpanel">
{% set title %}{% trans "Retired" %}{% endset %}
{% set option1 = "No"|trans %}
{% set option2 = "Yes"|trans %}
{% set values = [{id: 0, value: option1}, {id: 1, value: option2}] %}
{{ inline.dropdown("retired", "single", title, 0, values, "id", "value") }}
{% set title %}{% trans "Show" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Only Used"|trans %}
{% set option3 = "Only Unused"|trans %}
{% set values = [{id: 1, value: option1}, {id: 2, value: option2}, {id: 3, value: option3}] %}
{{ inline.dropdown("layoutStatusId", "single", title, 1, values, "id", "value") }}
{% set title %}{% trans "Description" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "1st line"|trans %}
{% set option3 = "Widget List"|trans %}
{% set values = [{id: 1, value: option1}, {id: 2, value: option2}, {id: 3, value: option3}] %}
{{ inline.dropdown("showDescriptionId", "single", title, 2, values, "id", "value") }}
{% if currentUser.featureEnabled("library.view") %}
{% set title %}{% trans "Media" %}{% endset %}
{{ inline.input("mediaLike", title) }}
{% endif %}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title) }}
{% set title %}{% trans "Modified Since" %}{% endset %}
{{ inline.date("modifiedSinceDt", title) }}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="layouts" class="table table-striped responsive nowrap" data-content-type="layout" data-content-id-name="layoutId" data-state-preference-name="layoutGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Duration" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Orientation" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Valid?" %}</th>
<th>{% trans "Stats?" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Layout ID" %}</th>
<th>{% trans "Code" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#layouts").DataTable({
language: dataTablesLanguage,
lengthMenu: [10, 25, 50, 100, 250, 500],
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
dataType: 'json',
order: [[1, "asc"]],
ajax: {
url: "{{ url_for("layout.search") }}",
data: function (d) {
$.extend(d, $("#layouts").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
columns: [
{"data": "campaignId", responsivePriority: 1},
{
"data": "layout",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"name": "publishedStatus",
responsivePriority: 2,
"data": function (data, type) {
if (data.publishedDate != null) {
var now = moment();
var published = moment(data.publishedDate);
var differenceMinutes = published.diff(now, 'minutes');
var momentDifference = moment(now).to(published);
if (differenceMinutes < -5) {
return data.publishedStatus.concat(" - ", translations.publishedStatusFailed);
} else {
return data.publishedStatus.concat(" - ", translations.publishedStatusFuture + " " + momentDifference);
}
} else {
return data.publishedStatus;
}
}
},
{
"name": "description",
"data": null,
responsivePriority: 10,
"render": {"_": "description", "display": "descriptionFormatted", "sort": "description"}
},
{
"name": "duration",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.duration;
return dataTableTimeFromSeconds(data.duration, type);
}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
responsivePriority: 3,
"data": dataTableCreateTags
},{% endif %}
{ data: 'orientation', responsivePriority: 10, visible: false},
{
responsivePriority: 5,
data: 'thumbnail',
render: function(data, type, row) {
if (type !== 'display') {
return row.layoutId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
} else {
var addUrl = '{{ url_for("layout.thumbnail.add", {id: ":id"}) }}'.replace(':id', row.layoutId);
return '<a class="img-replace generate-layout-thumbnail" data-type="image" href="' + addUrl + '">' +
'<img class="img-fluid" src="{{ theme.uri("img/thumbs/placeholder.png") }}" alt="{{ "Add Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"name": "status",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.status;
var icon = "";
if (data.status == 1)
icon = "fa-check";
else if (data.status == 2)
icon = "fa-exclamation";
else if (data.status == 3)
icon = "fa-cogs";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.statusDescription) + ((data.statusMessage == null) ? "" : " - " + (data.statusMessage)) + '"></span>';
}
},
{
"name": "enableStat",
responsivePriority: 4,
"data": function (data) {
var icon = "";
if (data.enableStat == 1)
icon = "fa-check";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.enableStatDescription) + '"></span>';
}
},
{
"data": "createdDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
data: "modifiedDt",
responsivePriority: 6,
render: dataTableDateFromIso,
visible: true
},
{
data: "layoutId",
visible: false,
responsivePriority: 4
},
{"data": "code", "visible":false, responsivePriority: 4},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#layouts").closest(".XiboGrid").find(".FilterDiv form") }, dataTableCreateTagEvents);
table.on('draw', function(e, settings) {
$('#' + e.target.id + ' .generate-layout-thumbnail').on('click', function(e) {
e.preventDefault();
var $anchor = $(this);
$.ajax({
url: $anchor.attr('href'),
method: 'POST',
success: function() {
$anchor.find('img').attr('src', $anchor.attr('href'));
$anchor.removeClass('generate-layout-thumbnail').attr('data-toggle', 'lightbox');
}
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#layouts_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function() {
table.ajax.reload();
});
// Bind to the layout add button
$('button.layout-add-button').on('click', function() {
let currentWorkingFolderId =
$("#layouts")
.closest(".XiboGrid")
.find(".FilterDiv form")
.find('#folderId').val()
// Submit the URL provided as a POST request.
$.ajax({
type: 'POST',
url: $(this).attr('href'),
cache: false,
data : {folderId : currentWorkingFolderId},
dataType: 'json',
success: function(response, textStatus, error) {
if (response.success && response.id) {
XiboRedirect('{{ url_for("layout.designer", {id: ':id'}) }}'.replace(':id', response.id));
} else {
if (response.login) {
LoginBox(response.message);
} else {
SystemMessage(response.message ?? '{{ "Unknown Error"|trans }}', false);
}
}
},
error: function(xhr, textStatus, errorThrown) {
SystemMessage(xhr.responseText, false);
},
});
});
});
$("#layoutUploadForm").click(function(e) {
e.preventDefault();
var currentWorkingFolderId = $('#folderId').val();
// Open the upload dialog with our options.
openUploadForm({
url: "{{ url_for("layout.import") }}",
title: "{{ "Upload Layout"|trans }}",
videoImageCovers: false,
buttons: {
main: {
label: "{{ "Done"|trans }}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
layoutImport: true,
updateInAllChecked: {% if settings.LIBRARY_MEDIA_UPDATEINALL_CHECKB == 1 %}true{% else %}false{% endif %},
deleteOldRevisionsChecked: {% if settings.LIBRARY_MEDIA_DELETEOLDVER_CHECKB == 1 %}true{% else %}false{% endif %},
trans: {
addFiles: "{{ "Add Layout Export ZIP Files"|trans }}",
startUpload: "{{ "Start Import"|trans }}",
cancelUpload: "{{ "Cancel Import"|trans }}",
replaceExistingMediaMessage: "{{ "Replace Existing Media?"|trans }}",
importTagsMessage: "{{ "Import Tags?"|trans }}",
useExistingDataSetsMessage: "{{ "Use existing DataSets matched by name?"|trans }}",
dataSetDataMessage: "{{ "Import DataSet Data?"|trans }}",
fallbackMessage: "{{ "Import Widget Fallback Data?"|trans }}",
selectFolder: "{{ "Select Folder"|trans }}",
selectFolderTitle: "{{ "Change Current Folder location"|trans }}",
selectedFolder: "{{ "Current Folder"|trans }}:",
selectedFolderTitle: "{{ "Upload files to this Folder"|trans }}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "zip"
},
currentWorkingFolderId: currentWorkingFolderId,
folderSelector: true
},
formOpenedEvent: function () {
// Configure the active behaviour of the checkboxes
$("#useExistingDataSets").on("click", function () {
$("#importDataSetData").prop("disabled", ($(this).is(":checked")));
});
},
uploadDoneEvent: function (data) {
XiboDialogClose();
table.ajax.reload();
}
});
});
function layoutExportFormSubmit() {
var $form = $("#layoutExportForm");
window.location = $form.attr("action") + "?" + $form.serialize();
setTimeout(function() {
XiboDialogClose();
}, 1000);
}
function assignLayoutToCampaignFormSubmit() {
var form = $("#layoutAssignCampaignForm");
var url = form.prop("action").replace(":id", form.find("#campaignId").val());
$.ajax({
type: form.attr("method"),
url: url,
data: {layoutId: form.data().layoutId},
cache: false,
dataType:"json",
success: XiboSubmitResponse
});
}
function setEnableStatMultiSelectFormOpen(dialog) {
var $input = $('<input type=checkbox id="enableStat" name="enableStat"> {{ "Enable Stats Collection?"|trans }} </input>');
var $helpText = $('<span class="help-block">{{ "Check to enable the collection of Proof of Play statistics for the selected items."|trans }}</span>');
$input.on('change', function() {
dialog.data().commitData = {enableStat: $(this).val()};
});
$(dialog).find('.modal-body').append($input);
$(dialog).find('.modal-body').append($helpText);
}
function layoutPublishFormOpen() {
// Nothing to do here, but we use the same form on the layout designer and have a callback registered there
}
function layoutEditFormSaved() {
// Nothing to do here.
}
</script>
{% endblock %}

View File

@@ -0,0 +1,591 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Library"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabledCount(["library.add", "library.modify"]) > 0 or settings.SETTING_LIBRARY_TIDY_ENABLED == 1 %}
{% if currentUser.featureEnabled("library.add") %}
<button class="btn btn-icon btn-success" href="#" id="libraryUploadForm" title="{% trans "Add a new media item to the library" %}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new media item to the library via external URL" %}" href="{{ url_for("library.uploadUrl.form") }}"><i class="fa fa-link" aria-hidden="true"></i></button>
{% endif %}
{% if settings.SETTING_LIBRARY_TIDY_ENABLED == 1 and currentUser.featureEnabled("library.modify") %}
<button class="btn btn-icon btn-warning XiboFormButton btn-tidy" title="{% trans "Run through the library and remove unused and unnecessary files" %}" href="{{ url_for("library.tidy.form") }}">
<svg class="icon icon-broom-pantry" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
<!-- dustpan -->
<path d="M3 6h6l2 6v5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6z" fill="currentColor" opacity="0.08"/>
<path d="M9 6v0" />
<path d="M3.5 7.5L8 7.5" />
<!-- broom handle -->
<path d="M14 3l6 6-7 7" />
<!-- bristles -->
<path d="M11 14l4.5-4.5M12 15l5-5M13 16l5.5-5.5" />
<!-- small hand grip accent -->
<circle cx="14.5" cy="4.5" r="0.5" fill="currentColor" />
</g>
</svg>
</button>
{% endif %}
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Media" %}</h1>
<p class="text-muted">{% trans "Manage your media library." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="libraryView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Media" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-up"></i>
</button>
</div>
<div class="ots-filter-content" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("mediaId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('media', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set attributes = [
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" }
] %}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("ownerId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Owner User Group" %}{% endset %}
{% set helpText %}{% trans "Show items owned by users in the selected User Group." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("group.search") },
{ name: "data-search-term", value: "group" },
{ name: "data-id-property", value: "groupId" },
{ name: "data-text-property", value: "group" },
{ name: "data-initial-key", value: "userGroupId" },
] %}
{{ inline.dropdown("ownerUserGroupId", "single", title, "", null, "groupId", "group", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Type" %}{% endset %}
{{ inline.dropdown("type", "single", title, "", [{"type": none, "name": ""}]|merge(modules), "type", "name") }}
{% set title %}{% trans "Retired" %}{% endset %}
{% set values = [{id: 0, value: "No"}, {id: 1, value: "Yes"}] %}
{{ inline.dropdown("retired", "single", title, 0, values, "id", "value") }}
{{ inline.hidden("folderId") }}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title, layoutId) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Landscape"|trans %}
{% set option3 = "Portrait"|trans %}
{% set values = [{id: '', value: option1}, {id: 'landscape', value: option2}, {id: 'portrait', value: option3}] %}
{{ inline.dropdown("orientation", "single", title, '', values, "id", "value") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 dashboard-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none ots-grid-controller">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="libraryItems" class="table table-striped responsive nowrap" data-content-type="media" data-content-id-name="mediaId" data-state-preference-name="libraryGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Type" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tag" %}</th>{% endif %}
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Duration" %}</th>
<th>{% trans "Duration (seconds)" %}</th>
<th>{% trans "Size" %}</th>
<th>{% trans "Size (bytes)" %}</th>
<th>{% trans "Resolution" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Revised" %}</th>
<th>{% trans "Released" %}</th>
<th>{% trans "File Name" %}</th>
<th>{% trans "Stats?" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Expires" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#libraryItems").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.search") }}",
"data": function (d) {
$.extend(d, $("#libraryItems").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "mediaId", responsivePriority: 2},
{"data": "name", "render": dataTableSpacingPreformatted, responsivePriority: 3 },
{"data": "mediaType", responsivePriority: 2},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
responsivePriority: 2,
"visible": false,
"data": dataTableCreateTags
},{% endif %}
{
responsivePriority: 5,
data: 'thumbnail',
render: function(data, type, row) {
if (type !== 'display') {
return row.mediaId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data.replace('download', 'thumbnail') + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{
"name": "duration",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.duration;
return dataTableTimeFromSeconds(data.duration, type);
}
},
{"data": "duration", "visible": false, responsivePriority: 10},
{
"name": "fileSize",
responsivePriority: 3,
"data": null,
"render": {"_": "fileSize", "display": "fileSizeFormatted", "sort": "fileSize"}
},
{"data": "fileSize", "visible": false, responsivePriority: 10},
{
name: 'width',
data: function(data, type, row, meta) {
if (type !== 'display' || data.width === 0 || data.height === 0) {
return '';
}
return data.width + 'x' + data.height;
},
visible: false,
responsivePriority: 10
},
{"data": "owner", responsivePriority: 5},
{
"data": "groupsWithPermissions",
responsivePriority: 5,
"render": dataTableCreatePermissions
},
{"data": "revised", "render": dataTableTickCrossColumn, "visible": false, responsivePriority: 6},
{
"name": "released",
responsivePriority: 6,
"data": function (data, type) {
if (type != "display")
return data.released;
var icon = "";
if (data.released == 1)
icon = "fa-check";
else if (data.released == 0)
icon = "fa-cogs";
else if (data.released == 2)
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.releasedDescription) + '"></span>';
},
"visible": false
},
{"data": "fileName", responsivePriority: 500},
{
"name": "enableStat",
responsivePriority: 6,
"data": function (data) {
var icon = "";
if (data.enableStat == 'On')
icon = "fa-check";
else if (data.enableStat == 'Off')
icon = "fa-times";
else
icon = "fa-level-down";
return '<span class="fa ' + icon + '" title="' + (data.enableStatDescription) + '"></span>';
}
},
{
"data": "createdDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"data": "modifiedDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"name": "expires",
responsivePriority: 6,
"data": function (data, type) {
if (data.expires != null && data.expires != 0) {
var now = moment();
var expiresIn = moment.unix(data.expires);
var differenceMinutes = expiresIn.diff(now, 'minutes');
var momentDifference = moment(now).to(expiresIn);
if (differenceMinutes < -10 ) {
return data.mediaExpiryFailed;
} else {
return data.mediaExpiresIn.replace('%s', momentDifference);
}
} else {
return data.mediaNoExpiryDate;
}
}
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#libraryItems").closest(".XiboGrid").find(".FilterDiv form") } ,dataTableCreateTagEvents);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#libraryItems_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
$("#libraryUploadForm").click(function(e) {
e.preventDefault();
var currentWorkingFolderId = $('#folderId').val();
openUploadForm({
url: "{{ url_for("library.add") }}",
title: "{% trans "Add Media" %}",
initialisedBy: "library-upload",
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
trans: {
addFiles: "{% trans "Add files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}",
selectFolder: "{% trans "Select Folder" %}",
selectFolderTitle: "{% trans "Change Current Folder location" %}",
selectedFolder: "{% trans "Current Folder" %}:",
selectedFolderTitle: "{% trans "Upload files to this Folder" %}",
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ validExt }}"
},
updateInAllChecked: {% if settings.LIBRARY_MEDIA_UPDATEINALL_CHECKB == 1 %}true{% else %}false{% endif %},
deleteOldRevisionsChecked: {% if settings.LIBRARY_MEDIA_DELETEOLDVER_CHECKB == 1 %}true{% else %}false{% endif %},
currentWorkingFolderId: currentWorkingFolderId,
folderSelector: true
}
});
});
/**
* Media Edit form
*/
function mediaEditFormOpen(dialog) {
// Create a new button
var footer = dialog.find(".modal-footer");
var mediaId = dialog.find("#mediaEditForm").data().mediaId;
var validExtensions = dialog.find("#mediaEditForm").data().validExtensions;
var folderId = dialog.find("#mediaEditForm").data().folderId;
// Append
var replaceButton = $('<button class="btn btn-warning">{% trans "Replace" %}</button>');
replaceButton.click(function(e) {
e.preventDefault();
// Open the upload dialog with our options.
openUploadForm({
url: "{{ url_for("library.add") }}",
title: "{% trans "Upload media" %}",
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
multi: false,
oldMediaId: mediaId,
oldFolderId: folderId,
updateInAllChecked: {% if settings.LIBRARY_MEDIA_UPDATEINALL_CHECKB == 1 %}true{% else %}false{% endif %},
deleteOldRevisionsChecked: {% if settings.LIBRARY_MEDIA_DELETEOLDVER_CHECKB == 1 %}true{% else %}false{% endif %},
trans: {
addFiles: "{% trans "Add Replacement" %}",
startUpload: "{% trans "Start Replace" %}",
cancelUpload: "{% trans "Cancel Replace" %}",
updateInLayouts: {
title: "{% trans "Update this media in all layouts it is assigned to?" %}",
helpText: "{% trans "Note: It will only be updated in layouts you have permission to edit." %}"
},
deleteOldRevisions: {
title: "{% trans "Delete the old version?" %}",
helpText: "{% trans "Completely remove the old version of this media item if a new file is being uploaded." %}"
}
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: validExtensions,
validExtensionsMessage: "{{ "Valid extensions are %s" }}".replace("%s", validExtensions).replace(/\|/g, ", ")
}
},
uploadDoneEvent: function () {
XiboDialogClose();
table.ajax.reload();
}
});
});
footer.find(".btn-primary").before(replaceButton);
}
///
/// Library Usage Form
///
function usageFormOpen(dialog) {
// Displays tab
var usageTable = $("#usageReportTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.usage", {id: ':id'}) }}".replace(":id", $("#usageReportTable").data().mediaId),
"data": function(dataDisplay) {
$.extend(dataDisplay, $(dialog).find("#usageReportForm").serializeObject());
return dataDisplay;
}
},
"columns": [
{ "data": "displayId"},
{ "data": "display" },
{ "data": "description" }
]
});
usageTable.on('draw', dataTableDraw);
usageTable.on('processing.dt', dataTableProcessing);
// Layouts tab
var usageTableLayouts = $("#usageReportLayoutsTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.usage.layouts", {id: ':id'}) }}".replace(":id", $("#usageReportLayoutsTable").data().mediaId)
},
"columns": [
{ "data": "layoutId"},
{ "data": "layout" },
{ "data": "description" },
{
"orderable": false,
"data": dataTableButtonsColumn
}
]
});
usageTableLayouts.on('draw', dataTableDraw);
usageTableLayouts.on('processing.dt', dataTableProcessing);
}
function setDefaultMultiSelectFormOpen(dialog) {
{% set message = 'Force delete from any existing layouts, assignments, etc' %}
{% set message2 = 'Notify each Display that has this Media in its local storage to remove it immediately?' %}
var $input = $(
'<div class="form-group">' +
'<input type=checkbox id="forceDelete" name="forceDelete"> {{ message|trans|e }} </input>' +
'</div>'
);
var $input2 = $(
'<div class="form-group">' +
'<input type=checkbox id="purge" name="purge"> {{ message2|trans|e }} </input>' +
'</div>'
);
$(dialog).find('.modal-body').append($input, $input2);
$('#forceDelete, #purge').on('change', function() {
dialog.data().commitData = {
forceDelete: $('#forceDelete').val(),
purge: $('#purge').val()
};
});
}
function setEnableStatMultiSelectFormOpen(dialog) {
var $select = $('<select id="enableStat" name="enableStat" class="form-control">' +
'<option value="Off">{% trans %} Off {% endtrans %}</option>' +
'<option value="On">{% trans %} On {% endtrans %}</option>' +
'<option value="Inherit">{% trans %} Inherit {% endtrans %}</option>' +
'</select>');
$select.on('change', function() {
dialog.data().commitData = {enableStat: $(this).val()};
}).trigger('change');
$(dialog).find('.modal-body').append($select);
}
</script>
{% endblock %}

View File

@@ -1,129 +0,0 @@
{#
OTS Signage Modern Theme - Media Library Page Override
Two-column layout with folder panel on left, media grid on right
#}
{% extends "authed.twig" %}
{% block pageTitle %}Media Library{% endblock %}
{% block content %}
<div class="ots-theme two-column-layout">
<aside class="left-panel media-sidebar">
<div class="panel-header">
<h3>Folders</h3>
<button class="btn-icon-sm" aria-label="New folder">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div class="folder-tree">
<div class="folder-item active">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">All Files</span>
</div>
<div class="folder-item">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="folder-name">Root Folder</span>
</div>
<div class="folder-item">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</svg>
<span class="folder-name">Images</span>
</div>
<div class="folder-item">
<svg class="folder-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<span class="folder-name">Videos</span>
</div>
</div>
</aside>
<main class="content-panel">
<div class="page-header">
<h1>Media Library</h1>
<p class="text-muted">Upload and manage your images and videos for digital signage</p>
</div>
<div class="content-toolbar">
<div class="search-wrapper">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="search" placeholder="Search media…" class="form-control search-field" />
</div>
<div class="toolbar-actions">
<button class="btn btn-outline btn-sm">All Media</button>
<a href="{{ baseUrl }}/library/add" class="btn btn-primary btn-sm">Upload Media</a>
</div>
</div>
<div class="stat-row">
<div class="stat-box">
<div class="stat-label">Total Files</div>
<div class="stat-value">4</div>
</div>
<div class="stat-box">
<div class="stat-label">Storage Used</div>
<div class="stat-value">12.3 MB</div>
</div>
<div class="stat-box">
<div class="stat-label">Storage Limit</div>
<div class="stat-value">5 GB</div>
</div>
</div>
<div class="media-grid">
<div class="media-item">
<div class="media-thumbnail">
<img src="https://images.unsplash.com/photo-1444080748397-f442aa95c3e5?w=400&h=300&fit=crop" alt="Galaxy space" />
<span class="media-type-badge">Image</span>
</div>
<div class="media-info">
<p class="media-name">2000x1158</p>
<p class="media-size text-xs text-muted">3.3 MB • 1920x1112</p>
</div>
</div>
<div class="media-item">
<div class="media-thumbnail">
<img src="https://images.unsplash.com/photo-1478098711619-69891b0ec21a?w=400&h=300&fit=crop" alt="Cat portrait" />
<span class="media-type-badge">Image</span>
</div>
<div class="media-info">
<p class="media-name">Images.jpg</p>
<p class="media-size text-xs text-muted">5.2 KB • 194x260</p>
</div>
</div>
<div class="media-item">
<div class="media-thumbnail">
<img src="https://images.unsplash.com/photo-1577720643272-265b434c829c?w=400&h=300&fit=crop" alt="OTS Logo" />
<span class="media-type-badge">Image</span>
</div>
<div class="media-info">
<p class="media-name">OTS Logo</p>
<p class="media-size text-xs text-muted">2.9 KB • 360x350</p>
</div>
</div>
<div class="media-item">
<div class="media-thumbnail">
<img src="https://images.unsplash.com/photo-1590080876-8b7f22b5d5fa?w=400&h=300&fit=crop" alt="Sunrise Hotel" />
<span class="media-type-badge">Image</span>
</div>
<div class="media-info">
<p class="media-name">suncrest hotel l...</p>
<p class="media-size text-xs text-muted">4.1 KB • 5824x3401</p>
</div>
</div>
</div>
</main>
</div>
{% endblock %}

View File

@@ -0,0 +1,198 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Menu Boards"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("menuBoard.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new Menu Board" %}" href="{{ url_for("menuBoard.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Menu Boards" %}</h1>
<p class="text-muted">{% trans "Manage your menu boards and content." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-type="menuBoard" data-grid-name="menuBoardView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Menu Boards" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("menuId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input('code', title) }}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="menuBoards" class="table table-striped responsive nowrap" data-content-type="menuBoard" data-content-id-name="menuId" data-state-preference-name="menuBoardGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Permissions" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#menuBoards").DataTable({
"language": dataTablesLanguage,
"lengthMenu": [10, 25, 50, 100, 250, 500],
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
dataType: 'json',
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("menuBoard.search") }}",
"data": function (d) {
$.extend(d, $("#menuBoards").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "menuId", responsivePriority: 2},
{
"data": "name",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"data": "description",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"data": "code", responsivePriority: 3
},
{
"name": "modifiedDt",
"data": function (data) {
return moment.unix(data.modifiedDt).format(jsDateFormat);
}
},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#menuBoards_wrapper').find('.col-md-6').eq(1));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,198 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("playersoftware.add") %}
<button class="btn btn-icon btn-success" href="#" id="playerSoftwareUploadForm" title="{% trans "Upload a new Player Software file" %}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Player Versions" %}</h1>
<p class="text-muted">{% trans "Manage player software versions and downloads." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="playerSoftwareView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Player Versions" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-up"></i>
</button>
</div>
<div class="ots-filter-content" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Type" %}{% endset %}
{{ inline.dropdown("playerType", "single", title, "", [{"type": none, "typeShow": none}]|merge(types), "type", "typeShow") }}
{% set title %}{% trans "Version" %}{% endset %}
{{ inline.dropdown("playerVersion", "single", title, "", [{"version": none, "version": none}]|merge(versions), "version", "version") }}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input("playerCode", title) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 dashboard-card ots-table-card">
<table id="playerSoftwareItems" class="table table-striped" data-state-preference-name="playerSoftwareGrid">
<thead>
<tr>
<th>{% trans "Version ID" %}</th>
<th>{% trans "Player Version Name" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Version" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "File Name" %}</th>
<th>{% trans "Size" %}</th>
<th>{% trans "Created At" %}</th>
<th>{% trans "Modified At" %}</th>
<th>{% trans "Modified By" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
table = $("#playerSoftwareItems").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[2, "asc"]],
ajax: {
"url": "{{ url_for("playersoftware.search") }}",
"data": function (d) {
$.extend(d, $("#playerSoftwareItems").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "versionId", responsivePriority: 2},
{"data": "playerShowVersion", responsivePriority: 2},
{"data": "type", responsivePriority: 2},
{"data": "version", responsivePriority: 2},
{"data": "code", responsivePriority: 2},
{"data": "fileName", responsivePriority: 4},
{
"name": "size",
responsivePriority: 3,
"data": null,
"render": {"_": "size", "display": "fileSizeFormatted", "sort": "size"}
},
{"data": "createdAt", responsivePriority: 6, visible: false},
{"data": "modifiedAt", responsivePriority: 6, visible: false},
{"data": "modifiedBy", responsivePriority: 6, visible: false},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
],
createdRow: function (row, data, index) {
if (data.version === "" || data.version === null || data.code === 0) {
$(row).addClass('table-danger');
$(row).attr('Title', "{{ "Please set Player Software Version"|trans }}");
}
},
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#playerSoftwareItems_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
$("#playerSoftwareUploadForm").click(function(e) {
e.preventDefault();
openUploadForm({
url: "{{ url_for("playersoftware.add") }}",
title: "{% trans "Upload Version" %}",
videoImageCovers: false,
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
includeTagsInput: false,
multi: false,
trans: {
addFiles: "{% trans "Add files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}",
processing: "{% trans "Processing..." %}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ validExt }}"
},
updateInAllChecked: false,
deleteOldRevisionsChecked: false,
folderSelector: false
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,554 @@
{#
* Copyright (C) 2021 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Playlists"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("playlist.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add Playlist" %}" href="{{ url_for("playlist.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Playlists" %}</h1>
<p class="text-muted">{% trans "Create and manage content playlists." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="playlistView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Playlists" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-up"></i>
</button>
</div>
<div class="ots-filter-content" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="general-filter">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set attributes = [
{ name: "data-live-search", value: "true" },
{ name: "data-selected-text-format", value: "count > 4" }
] %}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Owner User Group" %}{% endset %}
{% set helpText %}{% trans "Show items owned by users in the selected User Group." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("group.search") },
{ name: "data-search-term", value: "group" },
{ name: "data-id-property", value: "groupId" },
{ name: "data-text-property", value: "group" },
{ name: "data-initial-key", value: "userGroupId" },
] %}
{{ inline.dropdown("ownerUserGroupId", "single", title, "", null, "groupId", "group", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title, layoutId) }}
</div>
<div class="tab-pane" id="advanced-filter">
{% set title %}{% trans "Show" %}{% endset %}
{% set values = [{id: 1, value: "All"}, {id: 2, value: "Only Used"}, {id: 3, value: "Only Unused"}] %}
{{ inline.dropdown("playlistStatusId", "single", title, 1, values, "id", "value") }}
{% if currentUser.featureEnabled("library.view") %}
{% set title %}{% trans "Media" %}{% endset %}
{{ inline.input("mediaLike", title) }}
{% endif %}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 dashboard-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none ots-grid-controller">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="playlists" class="table table-striped" data-content-type="playlist"
data-content-id-name="playlistId" data-state-preference-name="playlistGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Duration" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Dynamic?" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Stats?" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="dummyLayout" style="display:none"></div>
<div id="editor-container"></div>
<div class="loading-overlay">
<i class="fa fa-spinner fa-spin loading-icon"></i>
</div>
{% endblock %}
{% block javaScript %}
{# Add common files #}
{% include "editorTranslations.twig" %}
{% include "editorVars.twig" %}
<script src="{{ theme.rootUri() }}dist/playlistEditor.bundle.min.js?v={{ version }}&rev={{ revision }}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/codeEditor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/wysiwygEditor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/editorCommon.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script type="text/javascript" nonce="{{ cspNonce }}">
{# Custom translations #}
{% autoescape "js" %}
{# Insert custom translations here #}
{% endautoescape %}
var table;
$(document).ready(function () {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
// Create ourselves a little hidden layout for preview sizing, etc
$("#dummyLayout").html('<div id="layout" data-background-color="#000000" style="background-color: #000000" designer_scale="1"><div id="region_-1" zindex="1" tip_scale="1" designer_scale="1" width="800" height="450"></div></div>');
// Configure the DataTable
table = $("#playlists").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
"lengthMenu": [10, 25, 50, 100, 250, 500],
serverSide: true,
stateSave: true,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("playlist.search") }}",
"data": function (d) {
$.extend(d, $("#playlists").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "playlistId", responsivePriority: 2},
{
"data": "name",
responsivePriority: 3,
"render": dataTableSpacingPreformatted
},
{
"data": "duration",
responsivePriority: 3,
"render": function (data, type, row) {
if (type !== "display" && type !== "export")
return data;
if (row.requiresDurationUpdate === 1) {
return '<span class="fa fa-clock-o" title="{{ "Changes have been made and we are recalculating this Playlists duration" }}"></span>';
} else if (row.requiresDurationUpdate !== 0) {
return moment().startOf("day").seconds(data).format("H:mm:ss") + ' <span class="fa fa-clock-o" title="{{ "This duration will be updated at " }}' + moment(row.requiresDurationUpdate, "X").format(jsDateFormat) + '"></span>';
}
return dataTableTimeFromSeconds(data, type, row);
}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
responsivePriority: 4,
"data": dataTableCreateTags
},{% endif %}
{"data": "isDynamic", "render": dataTableTickCrossColumn, responsivePriority: 4},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 5,
"render": dataTableCreatePermissions
},
{
"data": "createdDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"data": "modifiedDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"name": "enableStat",
responsivePriority: 6,
"data": function (data) {
var icon = "";
if (data.enableStat == 'On')
icon = "fa-check";
else if (data.enableStat == 'Off')
icon = "fa-times";
else
icon = "fa-level-down";
return '<span class="fa ' + icon + '" title="' + (data.enableStatDescription) + '"></span>';
}
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', {form: $("#playlists").closest(".XiboGrid").find(".FilterDiv form")}, dataTableCreateTagEvents);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#playlists_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
// Playlist Add Form
// contains a grid on the populate tab
// hook up the grid
var mediaTable;
var nameFilter;
var tagFilter;
var exactTags;
var logicalOperator;
var logicalOperatorName;
var filterFolderId;
function playlistEditorFormOpen(formData) {
// Clear container
$('#editor-container').empty();
// Append form
$('#editor-container').append(formData.message);
}
function playlistFormOpen(dialog) {
mediaTable = null;
$(dialog).find("input[name=filterMediaName]").on("keyup", _.debounce(function () {
playlistFormPopulateMediaTable(dialog);
}, 500));
$(dialog).find("input[name=filterMediaTag], input[name=exactTags], select[name=logicalOperator], select[name=logicalOperatorName], select[name=filterFolderId]").on("change", function () {
playlistFormPopulateMediaTable(dialog);
});
// First time in there
playlistFormPopulateMediaTable(dialog);
// Run function to set the form submit behaviour
playlistAddFormOpen();
}
///
/// Playlist Usage Form
///
function usageFormOpen(dialog) {
// Displays tab
var usageTable = $("#usageReportTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("playlist.usage", {id:':id'}) }}".replace(":id", $("#usageReportTable").data().playlistId),
"data": function (dataDisplay) {
$.extend(dataDisplay, $(dialog).find("#usageReportForm").serializeObject());
return dataDisplay;
}
},
"columns": [
{"data": "displayId"},
{"data": "display"},
{"data": "description"}
]
});
usageTable.on('draw', dataTableDraw);
usageTable.on('processing.dt', dataTableProcessing);
// Layouts tab
var usageTableLayouts = $("#usageReportLayoutsTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("playlist.usage.layouts", {id:':id'}) }}".replace(":id", $("#usageReportLayoutsTable").data().playlistId)
},
"columns": [
{"data": "layoutId"},
{"data": "layout"},
{"data": "description"},
{
"orderable": false,
"data": dataTableButtonsColumn
}
]
});
usageTableLayouts.on('draw', dataTableDraw);
usageTableLayouts.on('processing.dt', dataTableProcessing);
}
function playlistFormPopulateMediaTable(dialog) {
nameFilter = $(dialog).find("input[name=filterMediaName]").val();
tagFilter = $(dialog).find("input[name=filterMediaTag]").val();
exactTags = $(dialog).find("input[name=exactTags]").is(':checked')
logicalOperator = $(dialog).find("select[name=logicalOperator]").val();
logicalOperatorName = $(dialog).find("select[name=logicalOperatorName]").val();
filterFolderId = $(dialog).find("select[name=filterFolderId]").val() ?? "";
if (nameFilter === "" && tagFilter === "" && filterFolderId === "") {
if (mediaTable != null) {
mediaTable.destroy();
mediaTable = null;
$("#playlistLibraryMedia tbody").empty();
}
return;
}
if (mediaTable != null) {
mediaTable.ajax.reload();
} else {
mediaTable = $("#playlistLibraryMedia").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true,
stateDuration: 0,
filter: false,
responsive: true,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.search") }}",
"data": function (d) {
$.extend(
d,
{
media: nameFilter,
tags: tagFilter,
folderId: filterFolderId,
assignable: 1,
exactTags: exactTags,
logicalOperator: logicalOperator,
logicalOperatorName: logicalOperatorName
}
);
}
},
"columns": [
{"data": "mediaId"},
{"data": "name"},
{"data": "mediaType"},
{% if currentUser.featureEnabled("tag.tagging") %}{"data": dataTableCreateTags},{% endif %}
{
"name": "duration",
"data": function (data, type) {
if (type !== "display")
return data.duration;
return moment().startOf("day").seconds(data.duration).format("H:mm:ss");
}
}
]
});
mediaTable.on('processing.dt', dataTableProcessing);
mediaTable.on('draw', {form: $(".playlistForm")}, dataTableCreateTagEvents);
}
}
function setEnableStatMultiSelectFormOpen(dialog) {
var $select = $('<select id="enableStat" name="enableStat" class="form-control">' +
'<option value="Off">{% trans %} Off {% endtrans %}</option>' +
'<option value="On">{% trans %} On {% endtrans %}</option>' +
'<option value="Inherit">{% trans %} Inherit {% endtrans %}</option>' +
'</select>');
$select.on('change', function () {
dialog.data().commitData = {enableStat: $(this).val()};
}).trigger('change');
$(dialog).find('.modal-body').append($select);
}
function playlistAddFormOpen() {
$("#playlistAddForm").off("submit").submit(function (e) {
e.preventDefault();
var form = $(this);
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
cache: false,
dataType: "json",
success: function (xhr, textStatus, error) {
XiboSubmitResponse(xhr, form);
if (xhr.success && xhr.data.isDynamic == 0) {
// Open the editor
openPlaylistEditorForm(xhr.id);
}
}
});
});
}
function openPlaylistEditorForm(playlistId) {
var requestPath = playlistEditorUrl;
// replace id if necessary/exists
requestPath = requestPath.replace(':id', playlistId);
$.ajax({
url: requestPath,
type: 'GET'
}).done(function (res) {
if (!res.success) {
// Login Form needed?
if (res.login) {
window.location.reload();
} else {
// Just an error we dont know about
if (res.message == undefined) {
console.error(res);
} else {
console.error(res.message);
}
}
} else {
// Clear container
$('#editor-container').empty();
// Append form
$('#editor-container').append(res.html);
}
}).fail(function (jqXHR, textStatus, errorThrown) {
// Output error to console
console.error(jqXHR, textStatus, errorThrown);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,133 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Resolutions"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("resolution.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new resolution for use on layouts" %}" href="{{ url_for("resolution.add.form") }}"><i class="fa fa-plus" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Resolutions" %}</h1>
<p class="text-muted">{% trans "Manage display resolutions." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="resolutionView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Resolutions" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Enabled" %}{% endset %}
{% set option1 %}{% trans "Yes" %}{% endset %}
{% set option2 %}{% trans "No" %}{% endset %}
{% set values = [{id: 1, value: option1}, {id: 0, value: option2}] %}
{{ inline.dropdown("enabled", "single", title, 1, values, "id", "value") }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 dashboard-card ots-table-card">
<table id="resolutions" class="table table-striped" data-state-preference-name="resolutionGrid">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Resolution" %}</th>
<th>{% trans "Width" %}</th>
<th>{% trans "Height" %}</th>
<th>{% trans "Enabled?" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
$(document).ready(function() {
var table = $("#resolutions").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("resolution.search") }}",
data: function (d) {
$.extend(d, $("#resolutions").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "resolutionId", responsivePriority: 2},
{"data": "resolution"},
{"data": "width"},
{"data": "height"},
{"data": "enabled"},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#resolutions_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,355 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% import "forms.twig" as forms %}
{% block title %}{{ "Schedule"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("schedule.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new Scheduled event" %}"
href="{{ url_for("schedule.add.form") }}"><i class="fa fa-plus" aria-hidden="true"></i>
</button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Schedule" %}</h1>
<p class="text-muted">{% trans "Schedule content to your displays." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="scheduleGridView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Schedule" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="schedule-filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab" aria-selected="true"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab" aria-selected="false"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="general-filter" role="tabpanel">
{% set title %}{% trans "Range" %}{% endset %}
{% set range %}{% trans "Custom" %}{% endset %}
{% set day %}{% trans "Day" %}{% endset %}
{% set week %}{% trans "Week" %}{% endset %}
{% set month %}{% trans "Month" %}{% endset %}
{% set year %}{% trans "Year" %}{% endset %}
{% set options = [
{ name: "custom", range: range },
{ name: "day", range: day },
{ name: "week", range: week },
{ name: "month", range: month },
{ name: "year", range: year },
] %}
{{ inline.dropdown("range", "single", title, "month", options, "name", "range", "", "date-range-input") }}
{% set title %}{% trans 'From Date' %}{% endset %}
{{ inline.dateTime("fromDt", title, "", "", "custom-date-range d-none", "", "") }}
{% set title %}{% trans 'To Date' %}{% endset %}
{{ inline.dateTime("toDt", title, "", "", "custom-date-range d-none", "", "") }}
{% set title %}{% trans "Date Controls" %}{% endset %}
<div class="form-group mr-1 mb-1 controls-date-range">
<div class="control-label mr-1" title=""
accesskey="">{{ title }}</div>
<div class="controls-date-inputs">
<div class="inputgroup date" id="dateInput">
<span class="btn btn-outline-primary date-open-button" role="button">
<i class="fa fa-calendar"></i>
</span>
<input type="text" class="form-control" id="dateInputLink" data-input style="display:none;"/>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary" data-calendar-nav="prev"><span class="fa fa-caret-left"></span> {% trans "Prev" %}</button>
<button type="button" class="btn btn-outline-secondary" data-calendar-nav="today">{% trans "Today" %}</button>
<button type="button" class="btn btn-secondary" data-calendar-nav="next">{% trans "Next" %} <span class="fa fa-caret-right"></span></button>
</div>
</div>
</div>
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans 'Event Type' %}{% endset %}
{{ inline.dropdown("eventTypeId", "single", title, "", [{eventTypeId: null, eventTypeName: "All"}]|merge(eventTypes), "eventTypeId", "eventTypeName") }}
{% set title %}{% trans "Layout / Campaign" %}{% endset %}
{% set helpText %}{% trans "Please select a Layout or Campaign for this Event to show" %}{% endset %}
<div class="form-group mr-1 mb-1">
<label class="control-label mr-1" for="campaignId" title=""
accesskey="">{{ title }}</label>
<select name="campaignId" id="campaignIdFilter" class="form-control"
data-search-url="{{ url_for("campaign.search") }}"
data-trans-campaigns="{% trans "Campaigns" %}"
data-trans-layouts="{% trans "Layouts" %}"
data-allow-clear="true"
data-width="100%"
title="{% trans "Layout / Campaign" %}"
data-placeholder="{% trans "Layout / Campaign" %}"
data-dropdownAutoWidth
>
</select>
</div>
{% set title %}{% trans "Displays" %}{% endset %}
<div class="form-group mr-1 mb-1 pagedSelect" style="min-width: 200px">
<label class="control-label mr-1" for="DisplayList" title=""
accesskey="">{{ title }}</label>
<select id="DisplayList" class="form-control" name="displaySpecificGroupIds[]"
data-width="100%"
data-placeholder="{% trans "Displays" %}"
data-search-url="{{ url_for("display.search") }}"
data-search-term="display"
data-id-property="displayGroupId"
data-text-property="display"
data-additional-property="displayGroupId"
data-allow-clear="true"
data-initial-key="displayGroupIds[]"
multiple>
</select>
</div>
{% set title %}{% trans "Display Groups" %}{% endset %}
<div class="form-group mr-2 mb-1 pagedSelect" style="min-width: 200px">
<label class="control-label mr-1" for="DisplayGroupList" title=""
accesskey="">{{ title }}</label>
<select id="DisplayGroupList" class="form-control" name="displayGroupIds[]"
data-width="100%"
data-placeholder="{% trans "Display Groups" %}"
data-search-url="{{ url_for("displayGroup.search") }}"
data-search-term="displayGroup"
data-id-property="displayGroupId"
data-text-property="displayGroup"
data-allow-clear="true"
data-initial-key="displayGroupIds[]"
multiple>
</select>
</div>
</div>
<div class="tab-pane" id="advanced-filter" role="tabpanel">
{% set label %}{% trans "Direct Schedule?" %}{% endset %}
{% set title %}{% trans "Show only events scheduled directly on selected Displays/Groups" %}{% endset %}
<div class="form-group ml-2 mr-3 mb-1">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="directSchedule" name="directSchedule">
<label class="form-check-label" title="{{ title }}" for="directSchedule" accesskey="">{{ label }}</label>
</div>
</div>
{% set title %}{% trans "Only show schedules which appear on all filtered displays/groups?" %}{% endset %}
{% set label %}{% trans "Shared Schedule?" %}{% endset %}
<div class="form-group ml-2 mr-3 mb-1">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="sharedSchedule" name="sharedSchedule">
<label class="form-check-label" title="{{ title }}" for="sharedSchedule" accesskey="">{{ label }}</label>
</div>
</div>
{% set title %}{% trans 'Geo Aware?' %}{% endset %}
{% set options = [
{ id: null, name: "Both"|trans },
{ id: 0, name: "No"|trans },
{ id: 1, name: "Yes"|trans }
] %}
{{ inline.dropdown("geoAware", "single", title, "both", options, "id", "name") }}
{% set title %}{% trans 'Recurring?' %}{% endset %}
{% set options = [
{ id: null, name: "Both" },
{ id: 0, name: "No"|trans },
{ id: 1, name: "Yes"|trans }
] %}
{{ inline.dropdown("recurring", "single", title, "both", options, "id", "name") }}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="XiboSchedule card dashboard-card ots-table-card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<a class="schedule-nav grid-nav nav-link active" id="grid-tab" href="#grid-view"
data-schedule-view="grid"
role="tab"
data-toggle="tab"><span>{% trans "Grid" %}</span></a>
</li>
<li class="nav-item">
<a class="schedule-nav calendar-nav nav-link" id="calendar-tab" href="#calendar-view"
data-schedule-view="calendar"
data-calendar-view="month"
role="tab"
data-toggle="tab"><span>{% trans "Calendar" %}</span></a>
</li>
</ul>
</div>
<div class="card-body">
<div class="xibo-calendar-header-container col-xl-12 d-inline-flex justify-content-between">
<div class="xibo-calendar-header text-center d-inline-flex">
<h1 class="page-header"></h1>
</div>
<div class="calendar-loading">
<span id="calendar-progress-table" class="fa fa-spin fa-cog"></span>
<span id="calendar-progress" class="fa fa-spin fa-cog"></span>
</div>
</div>
<div class="tab-content">
<div class="tab-pane active" id="grid-view">
<div class="XiboData pt-3">
<table id="schedule-grid" class="table table-striped w-100"
data-state-preference-name="scheduleGrid">
<thead>
<tr>
<th>{% trans 'ID' %}</th>
<th></th>
<th>{% trans 'Event Type' %}</th>
<th>{% trans 'Name' %}</th>
<th>{% trans 'Start' %}</th>
<th>{% trans 'End' %}</th>
<th>{% trans 'Event' %}</th>
<th>{% trans 'Campaign ID' %}</th>
<th>{% trans 'Display Groups' %}</th>
<th>{% trans 'SoV' %}</th>
<th>{% trans 'Max Plays per Hour' %}</th>
<th>{% trans 'Geo Aware?' %}</th>
<th>{% trans 'Recurring?' %}</th>
<th>{% trans 'Recurrence Description' %}</th>
<th>{% trans 'Recurrence Type' %}</th>
<th>{% trans 'Recurrence Interval' %}</th>
<th>{% trans 'Recurrence Repeats On' %}</th>
<th>{% trans 'Recurrence End' %}</th>
<th>{% trans 'Priority?' %}</th>
<th>{% trans 'Criteria?' %}</th>
<th>{% trans 'Created On' %}</th>
<th>{% trans 'Updated On' %}</th>
<th>{% trans 'Modified By' %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<div class="tab-pane" id="calendar-view">
<div class="row">
<div id="CalendarContainer"
data-agenda-link="{{ url_for("schedule.events", {id: ':id'}) }}"
data-calendar-type="{{ settings.CALENDAR_TYPE }}" class="col-sm-12"
data-default-lat="{{ defaultLat }}"
data-default-long="{{ defaultLong }}">
<div class="calendar-view" id="Calendar"></div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<div class="cal-legend">
<ul>
<li class="event-always"><span
class="fa fa-retweet"></span> {% trans "Always showing" %}</li>
<li class="event-info"><span
class="fa fa-desktop"></span> {% trans "Single Display" %}</li>
<li class="event-success"><span
class="fa fa-desktop"></span> {% trans "Multi Display" %}</li>
<li class="event-important"><span
class="fa fa-bullseye"></span> {% trans "Priority" %}</li>
<li class="event-special"><span
class="fa fa-repeat"></span> {% trans "Recurring" %}</li>
<li class="event-inverse"><span
class="fa fa-lock"></span> {% trans "View Only" %}</li>
<li class="event-command"><span
class="fa fa-wrench"></span> {% trans "Command" %}</li>
<li class="event-interrupt"><span
class="fa fa-hand-paper"></span> {% trans "Interrupt" %}</li>
<li class="event-geo-location"><span
class="fa fa-map-marker"></span> {% trans "Geo Location" %}</li>
<li class="event-action"><span
class="fa fa-paper-plane "></span> {% trans "Interactive Action" %}
</li>
<li class="event-sync"><span
class="fa fa-refresh"></span> {% trans "Synchronised" %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables #}
<script type="text/javascript" nonce="{{ cspNonce }}">
{# JS variables #}
var scheduleRecurrenceDeleteUrl = "{{ url_for("schedule.recurrence.delete.form", {id:':id'}) }}";
var layoutPreviewUrl = "{{ theme.rootUri() }}preview/layout/preview/:id";
var scheduleSearchUrl = "{{ url_for("schedule.search") }}";
var userAgendaViewEnabled = "{{ currentUser.featureEnabled('schedule.agenda') }}";
{# Custom translations #}
var schedulePageTrans = {
always: "{% trans "Always" %}",
adjustTimesofTimer: "{% trans "Adjust the times of this timer. To add or remove a day, use the Display Profile." %}",
daysOfTheWeek: {
monday: "{% trans "Monday" %}",
tuesday: "{% trans "Tuesday" %}",
wednesday: "{% trans "Wednesday" %}",
thursday: "{% trans "Thursday" %}",
friday: "{% trans "Friday" %}",
saturday: "{% trans "Saturday" %}",
sunday: "{% trans "Sunday" %}",
},
};
</script>
{# Add page source code bundle ( JS ) #}
<script src="{{ theme.rootUri() }}dist/leaflet.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/pages/schedule-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{% endblock %}

View File

@@ -0,0 +1,190 @@
{#
/**
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Sync Groups"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("display.syncAdd") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new Sync Group" %}" href="{{ url_for("syncgroup.form.add") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Sync Groups" %}</h1>
<p class="text-muted">{% trans "Create and manage synchronized Display groups." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="syncGroupGridView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Sync Groups" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.input("syncGroupId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Lead Display ID" %}{% endset %}
{{ inline.input("leadDisplayId", title) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 dashboard-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none ots-grid-controller">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="syncgroups" class="table table-striped" data-content-type="syncGroup" data-content-id-name="syncGroupId" data-state-preference-name="syncGroupGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Created Date" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Modified By" %}</th>
<th>{% trans "Publisher Port" %}</th>
<th>{% trans "Switch Delay" %}</th>
<th>{% trans "Video Pause Delay" %}</th>
<th>{% trans "Lead Display" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
let syncGroupTable;
$(document).ready(function() {
syncGroupTable = $("#syncgroups").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
"filter": false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("syncgroup.search") }}",
"data": function(d) {
$.extend(d, $("#syncgroups").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "syncGroupId", responsivePriority: 2 },
{ "data": "name", responsivePriority: 1 },
{ "data": "createdDt", responsivePriority: 2 },
{ "data": "modifiedDt", responsivePriority: 2 },
{ "data": "owner", responsivePriority: 3 },
{ "data": "modifiedByName", responsivePriority: 4 },
{ "data": "syncPublisherPort", responsivePriority: 3 },
{ "data": "syncSwitchDelay", responsivePriority: 3 },
{ "data": "syncVideoPauseDelay", responsivePriority: 3 },
{ "data": "leadDisplay", responsivePriority: 3 },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
syncGroupTable.on('draw', dataTableDraw);
syncGroupTable.on('processing.dt', dataTableProcessing);
dataTableAddButtons(syncGroupTable, $('#syncgroups_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
syncGroupTable.ajax.reload();
});
});
</script>
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-display-group-multi-delete-checkbox">
<div class="form-group row">
<div class="offset-sm-2 col-sm-10 mt-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="checkbox-confirmDelete" name="confirmDelete">
<label class="form-check-label" for="checkbox-confirmDelete">
{% endverbatim %}{{ "Are you sure you want to delete?"|trans }}{% verbatim %}
</label>
</div>
<small class="form-text text-muted">{% endverbatim %}{{ "Check to confirm deletion of the selected records."|trans }}{% verbatim %}</small>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

View File

@@ -0,0 +1,293 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Templates"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("template.add") %}
<button class="btn btn-icon btn-success XiboFormButton" title="{% trans "Add a new Template and jump to the layout editor." %}" href="{{ url_for("template.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-icon btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Templates" %}</h1>
<p class="text-muted">{% trans "Manage your reusable templates." %}</p>
</div>
<div class="widget dashboard-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="templateView">
<div class="XiboFilter card mb-3 bg-light dashboard-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Templates" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('template', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 dashboard-card ots-table-card">
<table id="templates" class="table table-striped" data-content-type="layout" data-content-id-name="layoutId" data-state-preference-name="templateGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Description" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Orientation" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Sharing" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
var table = $("#templates").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("template.search") }}",
"data": function(d) {
$.extend(d, $("#templates").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "layout", responsivePriority: 2},
{
"name": "publishedStatus",
responsivePriority: 2,
"data": function (data, type) {
if (data.publishedDate != null) {
var now = moment();
var published = moment(data.publishedDate);
var differenceMinutes = published.diff(now, 'minutes');
var momentDifference = moment(now).to(published);
if (differenceMinutes < -5) {
return data.publishedStatus.concat(" - ", translations.publishedStatusFailed);
} else {
return data.publishedStatus.concat(" - ", translations.publishedStatusFuture + " " + momentDifference);
}
} else {
return data.publishedStatus;
}
}
},
{ "data": "owner", responsivePriority: 3},
{
"name": "description",
"data": null,
responsivePriority: 3,
"render": {"_": "description", "display": "descriptionWithMarkup", "sort": "description"}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
"data": dataTableCreateTags,
responsivePriority: 3
},{% endif %}
{ data: 'orientation', responsivePriority: 10, visible: false},
{
responsivePriority: 3,
data: 'thumbnail',
render: function (data, type, row) {
if (type !== 'display') {
return row.layoutId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
} else {
var addUrl = '{{ url_for("layout.thumbnail.add", {id: ":id"}) }}'.replace(':id', row.layoutId);
return '<a class="img-replace generate-layout-thumbnail" href="' + addUrl + '">' +
'<img class="img-fluid" src="{{ theme.uri("img/thumbs/placeholder.png") }}" alt="{{ "Add Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#templates").closest(".XiboGrid").find(".FilterDiv form") } ,dataTableCreateTagEvents);
table.on('draw', function(e, settings) {
$('#' + e.target.id + ' .generate-layout-thumbnail').on('click', function(e) {
e.preventDefault();
var $anchor = $(this);
$.ajax({
url: $anchor.attr('href'),
method: 'POST',
success: function() {
$anchor.find('img').attr('src', $anchor.attr('href'));
$anchor.removeClass('generate-layout-thumbnail').attr('data-toggle', 'lightbox');
}
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#templates_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function templateFormOpen() {
if ($('#folder-tree-form-modal').length === 0) {
// compile tree folder modal and append it to Form
var folderTreeModal = templates['folder-tree'];
var treeConfig = {"container": "container-folder-form-tree", "modal": "folder-tree-form-modal"};
treeConfig.trans = translations.folderTree;
$("body").append(folderTreeModal(treeConfig));
$("#folder-tree-form-modal").on('hidden.bs.modal', function () {
// Fix for 2nd/overlay modal
$('.modal:visible').length && $(document.body).addClass('modal-open');
$(this).data('bs.modal', null);
});
}
// select current working folder if one is selected in the grid
if ($('#container-folder-tree').jstree("get_selected", true)[0] !== undefined) {
$('#templateAddForm' + ' #folderId').val($('#container-folder-tree').jstree("get_selected", true)[0].id);
}
initJsTreeAjax($('#folder-tree-form-modal').find('#container-folder-form-tree'), 'templateAddForm', true, 600);
$("#templateAddForm").submit(function(e) {
e.preventDefault();
var form = $(this);
var url = $(this).data().redirect;
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
cache: false,
dataType:"json",
success: function(xhr, textStatus, error) {
XiboSubmitResponse(xhr, form);
if (xhr.success) {
// Reload the designer
XiboRedirect(url.replace(":id", xhr.id));
}
}
});
});
}
function layoutPublishFormOpen() {
// Nothing to do here, but we use the same form on the layout designer and have a callback registered there
}
function layoutEditFormSaved() {
// Nothing to do here.
}
</script>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{#
OTS Signage Theme override
Optional dashboard message block included with ignore missing
#}

View File

@@ -0,0 +1,22 @@
{#
OTS Signage Theme - JavaScript and CSS injection
This file is auto-included by Xibo's base.twig at the end of the document
NOTE: CSS and JS are INLINED to bypass web server MIME type issues with /custom/ paths
This ensures all styles and scripts load regardless of web server routing configuration
#}
<!-- Theme CSS overrides - INLINED to bypass MIME type issues -->
<style nonce="{{ cspNonce }}">
{% include "override-styles.twig" %}
</style>
<!-- DataTables contrast fixes - INLINED to override core DataTables defaults -->
<style nonce="{{ cspNonce }}">
{% include "datatable-contrast.twig" %}
</style>
<!-- Theme JavaScript - INLINED to bypass MIME type issues -->
<script nonce="{{ cspNonce }}">
{% include "theme-scripts.twig" %}
</script>

View File

@@ -0,0 +1,785 @@
/**
* OTS Signage Modern Theme - Client-Side Utilities
* Sidebar toggle, dropdown menus, and UI interactions
*/
(function() {
'use strict';
// Apply saved or system-preferred theme as early as possible to avoid
// a flash from dark -> light when navigating between pages.
(function() {
try {
var stored = localStorage.getItem('ots-theme-mode');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var initial = stored || (prefersLight ? 'light' : 'light');
if (initial === 'light') {
document.documentElement.classList.add('ots-light-mode');
if (document.body) document.body.classList.add('ots-light-mode');
} else {
document.documentElement.classList.remove('ots-light-mode');
if (document.body) document.body.classList.remove('ots-light-mode');
}
} catch (err) {
// ignore failures (e.g. localStorage unavailable)
}
})();
const STORAGE_KEYS = {
sidebarCollapsed: 'otsTheme:sidebarCollapsed'
};
/**
* Measure sidebar width and set CSS variable for layout
*/
function updateSidebarWidth() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const collapsed = sidebar.classList.contains('collapsed');
// If called with a forced mode, use the stored defaults
const forceMode = updateSidebarWidth._forceMode || null;
const base = (forceMode === 'full') ? (window.__otsFullSidebarWidth || 256)
: (forceMode === 'collapsed') ? (window.__otsCollapsedSidebarWidth || 70)
: (collapsed ? 70 : sidebar.offsetWidth || sidebar.getBoundingClientRect().width || 240);
const padding = 5;
const value = Math.max(70, Math.round(base + padding));
// Apply CSS variable used by layout and also set an inline width fallback
document.documentElement.style.setProperty('--ots-sidebar-width', `${value}px`);
try {
// Inline width helps force an immediate reflow when CSS rules/important flags interfere
// Use setProperty with 'important' so stylesheet !important rules can't override it.
sidebar.style.setProperty('width', `${value}px`, 'important');
// Force reflow to encourage the browser to apply the new sizing immediately
// eslint-disable-next-line no-unused-expressions
sidebar.offsetHeight;
} catch (err) {
try { sidebar.style.width = `${value}px`; } catch (e) { /* ignore */ }
}
// Debug logging to help identify timing/specifity issues in the wild
if (window.__otsDebug) {
console.log('[OTS] updateSidebarWidth', { collapsed, base, value, cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width') });
}
}
// Helper to request a forced width update
function forceSidebarWidthMode(mode) {
updateSidebarWidth._forceMode = mode; // 'full' | 'collapsed' | null
updateSidebarWidth();
updateSidebarWidth._forceMode = null;
}
/**
* Measure the sidebar header bottom and set the top padding of the nav list
* so nav items always begin below the header (logo + buttons).
*/
function updateSidebarNavOffset() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const header = sidebar.querySelector('.sidebar-header');
const nav = sidebar.querySelector('.sidebar-nav, .ots-sidebar-nav');
if (!nav) return;
const sidebarRect = sidebar.getBoundingClientRect();
const headerRect = header ? header.getBoundingClientRect() : null;
let offset = 0;
if (headerRect) {
offset = Math.max(0, Math.ceil(headerRect.bottom - sidebarRect.top));
} else if (header) {
offset = header.offsetHeight || 0;
}
const gap = 8;
const paddingTop = offset > 0 ? offset + gap : '';
if (paddingTop) {
try {
nav.style.setProperty('padding-top', `${paddingTop}px`, 'important');
} catch (err) {
nav.style.paddingTop = `${paddingTop}px`;
}
if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset applied', { paddingTop });
} else {
try {
nav.style.removeProperty('padding-top');
} catch (err) {
nav.style.paddingTop = '';
}
if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset cleared');
}
}
/**
* Measure the sidebar and set an explicit left margin on the page wrapper
* so the gap between the sidebar and page content is exactly 5px.
*/
function updateSidebarGap() {
const sidebar = document.querySelector('.ots-sidebar');
// target likely content containers in this app
const targets = [
document.getElementById('page-wrapper'),
document.querySelector('.ots-main'),
document.getElementById('content-wrapper'),
document.querySelector('#content')
].filter(Boolean);
if (!sidebar || !targets.length) return;
const gap = (typeof window.__otsDesiredSidebarGap !== 'undefined') ? Number(window.__otsDesiredSidebarGap) : 0; // desired gap in px (default 0)
const rect = sidebar.getBoundingClientRect();
// desired inner left padding (allows trimming space inside the content area)
const desiredInnerPadding = (typeof window.__otsDesiredPagePaddingLeft !== 'undefined') ? Number(window.__otsDesiredPagePaddingLeft) : 8;
targets.forEach(pageWrapper => {
const pageRect = pageWrapper.getBoundingClientRect();
const computed = window.getComputedStyle(pageWrapper);
const currentMargin = parseFloat(computed.marginLeft) || 0;
const currentGap = Math.round(pageRect.left - rect.right);
// Calculate how much to adjust margin-left so gap becomes `gap`.
const delta = currentGap - gap;
const newMargin = Math.max(0, Math.round(currentMargin - delta));
try {
pageWrapper.style.setProperty('margin-left', `${newMargin}px`, 'important');
pageWrapper.style.setProperty('padding-left', `${desiredInnerPadding}px`, 'important');
} catch (err) {
pageWrapper.style.marginLeft = `${newMargin}px`;
pageWrapper.style.paddingLeft = `${desiredInnerPadding}px`;
}
// Also adjust common child wrapper padding if present
try {
const inner = pageWrapper.querySelector('.page-content') || pageWrapper.querySelector('.ots-content') || pageWrapper.querySelector('.container');
if (inner) inner.style.setProperty('padding-left', `${desiredInnerPadding}px`, 'important');
} catch (err) {}
if (window.__otsDebug) console.log('[OTS] updateSidebarGap', {
target: pageWrapper.tagName + (pageWrapper.id ? '#'+pageWrapper.id : ''),
sidebarWidth: rect.width,
sidebarRight: rect.right,
pageLeft: pageRect.left,
currentGap,
newMargin
});
// Detect narrow intervening elements (visual separator) and neutralize their visuals
try {
const sampleXs = [Math.round(rect.right + 2), Math.round((rect.right + pageRect.left) / 2), Math.round(pageRect.left - 2)];
const ys = [Math.floor(window.innerHeight / 2), Math.floor(window.innerHeight / 4), Math.floor(window.innerHeight * 0.75)];
const seen = new Set();
sampleXs.forEach(x => {
ys.forEach(y => {
try {
const els = document.elementsFromPoint(x, y) || [];
els.forEach(el => {
if (!el || el === document.documentElement || el === document.body) return;
if (el === sidebar || el === pageWrapper) return;
const b = el.getBoundingClientRect();
// narrow vertical candidates between sidebar and content
if (b.left >= rect.right - 4 && b.right <= pageRect.left + 4 && b.width <= 80 && b.height >= 40) {
const id = el.tagName + (el.id ? '#'+el.id : '') + (el.className ? '.'+el.className.split(' ').join('.') : '');
if (seen.has(id)) return;
seen.add(id);
try {
el.style.setProperty('background', 'transparent', 'important');
el.style.setProperty('background-image', 'none', 'important');
el.style.setProperty('box-shadow', 'none', 'important');
el.style.setProperty('border', 'none', 'important');
el.style.setProperty('pointer-events', 'none', 'important');
if (window.__otsDebug) console.log('[OTS] neutralized intervening element', { id, rect: b });
} catch (err) {}
}
});
} catch (err) {}
});
});
} catch (err) {}
});
}
function debounce(fn, wait) {
let t;
return function () {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, arguments), wait);
};
}
/**
* Reflect sidebar open/collapsed state on the document body
*/
function updateSidebarStateClass() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const body = document.body;
const isCollapsed = sidebar.classList.contains('collapsed');
if (!isCollapsed) {
body.classList.add('ots-sidebar-open');
} else {
body.classList.remove('ots-sidebar-open');
}
}
/**
* Initialize sidebar toggle functionality
*/
function initSidebarToggle() {
const toggleBtn = document.querySelector('[data-action="toggle-sidebar"]');
const sidebar = document.querySelector('.ots-sidebar');
const collapseBtn = document.querySelector('.sidebar-collapse-btn-visible');
const expandBtn = document.querySelector('.sidebar-expand-btn');
const body = document.body;
if (!sidebar) return;
if (toggleBtn) {
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.toggle('active');
});
}
if (collapseBtn) {
const isCollapsed = localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true';
if (isCollapsed) {
sidebar.classList.add('collapsed');
body.classList.add('ots-sidebar-collapsed');
updateSidebarStateClass();
updateSidebarGap();
}
collapseBtn.addEventListener('click', function(e) {
e.preventDefault();
const nowCollapsed = !sidebar.classList.contains('collapsed');
sidebar.classList.toggle('collapsed');
body.classList.toggle('ots-sidebar-collapsed', nowCollapsed);
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, nowCollapsed ? 'true' : 'false');
// Force collapsed width immediately
forceSidebarWidthMode('collapsed');
// Recalculate nav offset so items remain below header after collapse
updateSidebarNavOffset();
// Ensure page content gap is updated for collapsed width
updateSidebarGap();
// Re-run shortly after to catch any late layout changes
setTimeout(updateSidebarGap, 80);
updateSidebarStateClass();
// Debug state after toggle
try {
console.log('[OTS] collapseBtn clicked', {
nowCollapsed,
classes: sidebar.className,
inlineStyle: sidebar.getAttribute('style'),
computedWidth: getComputedStyle(sidebar).width,
cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width')
});
} catch (err) {}
});
}
if (expandBtn) {
expandBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.remove('collapsed');
body.classList.remove('ots-sidebar-collapsed');
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false');
// Force full width when expanding
forceSidebarWidthMode('full');
// Recalculate nav offset after expanding
updateSidebarNavOffset();
// Ensure page content gap is updated for expanded width
updateSidebarGap();
setTimeout(updateSidebarGap, 80);
updateSidebarStateClass();
try {
console.log('[OTS] expandBtn clicked', {
classes: sidebar.className,
inlineStyle: sidebar.getAttribute('style'),
computedWidth: getComputedStyle(sidebar).width,
cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width')
});
} catch (err) {}
});
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function(e) {
if (window.innerWidth <= 768) {
const isClickInsideSidebar = sidebar.contains(e.target);
const isClickOnToggle = toggleBtn && toggleBtn.contains(e.target);
if (!isClickInsideSidebar && !isClickOnToggle && sidebar.classList.contains('active')) {
sidebar.classList.remove('active');
updateSidebarStateClass();
}
}
});
// Ensure initial state class is set
updateSidebarStateClass();
}
/**
* Initialize sidebar section collapse/expand functionality
*/
function initSidebarSectionToggles() {
const groupToggles = document.querySelectorAll('.sidebar-group-toggle');
groupToggles.forEach(toggle => {
const group = toggle.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
const caret = toggle.querySelector('.sidebar-group-caret');
if (submenu) {
const isOpen = group.classList.contains('is-open');
submenu.style.display = isOpen ? 'block' : 'none';
toggle.setAttribute('aria-expanded', isOpen.toString());
}
toggle.addEventListener('click', function(e) {
e.preventDefault();
const group = toggle.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const isOpen = group.classList.contains('is-open');
group.classList.toggle('is-open', !isOpen);
toggle.setAttribute('aria-expanded', (!isOpen).toString());
submenu.style.display = isOpen ? 'none' : 'block';
});
if (caret) {
caret.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggle.click();
});
}
});
// Capture-phase handler to override any conflicting listeners
document.addEventListener('click', function(e) {
const caret = e.target.closest('.sidebar-group-caret');
const toggle = e.target.closest('.sidebar-group-toggle');
const target = toggle || (caret ? caret.closest('.sidebar-group-toggle') : null);
if (!target) return;
e.preventDefault();
e.stopPropagation();
const group = target.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const isOpen = group.classList.contains('is-open');
group.classList.toggle('is-open', !isOpen);
target.setAttribute('aria-expanded', (!isOpen).toString());
submenu.style.display = isOpen ? 'none' : 'block';
}, true);
}
/**
* Initialize dropdown menus
*/
function initDropdowns() {
const dropdowns = document.querySelectorAll('.dropdown');
dropdowns.forEach(dropdown => {
const button = dropdown.querySelector('.dropdown-menu');
if (!button) return;
const menu = dropdown.querySelector('.dropdown-menu');
// Toggle menu on button click
dropdown.addEventListener('click', function(e) {
if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) {
e.preventDefault();
dropdown.classList.toggle('active');
// If this dropdown contains the user menu, compute placement to avoid going off-screen
const menu = dropdown.querySelector('.dropdown-menu.ots-user-menu');
const trigger = dropdown.querySelector('#navbarUserMenu');
if (menu && trigger) {
// Reset any previous placement classes
menu.classList.remove('dropdown-menu-left');
menu.classList.remove('dropdown-menu-right');
// Use getBoundingClientRect for accurate placement
const trigRect = trigger.getBoundingClientRect();
// Ensure menu is in DOM and has an offsetWidth
const menuWidth = menu.offsetWidth || 220; // fallback estimate
const spaceRight = window.innerWidth - trigRect.right;
const spaceLeft = trigRect.left;
// Prefer opening to the right where possible, otherwise open to the left
if (spaceRight < menuWidth && spaceLeft > menuWidth) {
// not enough space on the right, open to left
menu.classList.add('dropdown-menu-left');
} else {
// default to right-aligned
menu.classList.add('dropdown-menu-right');
}
}
}
});
// Close menu when clicking outside
document.addEventListener('click', function(e) {
if (!dropdown.contains(e.target)) {
dropdown.classList.remove('active');
}
});
});
}
/**
* Initialize search functionality
*/
function initSearch() {
const searchForm = document.querySelector('.topbar-search');
if (!searchForm) return;
const input = searchForm.querySelector('.search-input');
if (input) {
input.addEventListener('focus', function() {
searchForm.style.borderColor = 'var(--color-primary)';
});
input.addEventListener('blur', function() {
searchForm.style.borderColor = 'var(--color-border)';
});
}
}
/**
* Initialize page specific interactions
*/
function initPageInteractions() {
// Displays page - folder selection
const folderItems = document.querySelectorAll('.folder-item');
folderItems.forEach(item => {
item.addEventListener('click', function() {
folderItems.forEach(f => f.classList.remove('active'));
this.classList.add('active');
});
});
// Filter collapse toggle
const filterCollapseBtn = document.querySelector('#ots-filter-collapse-btn');
const filterContent = document.querySelector('#ots-filter-content');
if (filterCollapseBtn && filterContent) {
const storageKey = `ots-filter-collapsed:${window.location.pathname}`;
let isCollapsed = false;
filterCollapseBtn.addEventListener('click', function() {
isCollapsed = !isCollapsed;
filterContent.classList.toggle('collapsed', isCollapsed);
// Rotate icon
const icon = filterCollapseBtn.querySelector('i');
icon.classList.toggle('fa-chevron-up');
icon.classList.toggle('fa-chevron-down');
// Save preference to localStorage
localStorage.setItem(storageKey, isCollapsed);
});
// Restore saved preference
const savedState = localStorage.getItem(storageKey);
if (savedState === 'true') {
isCollapsed = true;
filterContent.classList.add('collapsed');
const icon = filterCollapseBtn.querySelector('i');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
} else {
filterContent.classList.remove('collapsed');
}
}
// Displays page - folder tree toggle layout
const folderToggleBtn = document.querySelector('#folder-tree-select-folder-button');
const folderContainer = document.querySelector('.grid-with-folders-container');
const folderTree = document.querySelector('#grid-folder-filter');
if (folderToggleBtn && folderContainer && folderTree) {
let debounceTimeout;
const syncFolderLayout = () => {
// Check actual visibility using computed styles
const computedStyle = window.getComputedStyle(folderTree);
const isHidden = computedStyle.display === 'none' ||
computedStyle.visibility === 'hidden' ||
folderTree.offsetHeight === 0;
console.log('Folder collapse sync:', {
isHidden,
display: computedStyle.display,
visibility: computedStyle.visibility,
offsetHeight: folderTree.offsetHeight
});
folderContainer.classList.toggle('ots-folder-collapsed', !!isHidden);
// Log the result
console.log('Container classes:', folderContainer.className);
console.log('Grid template columns:', window.getComputedStyle(folderContainer).gridTemplateColumns);
// Force reflow
folderContainer.offsetHeight;
};
const debouncedSync = () => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(syncFolderLayout, 50);
};
// Watch for style/class changes on folderTree (let Xibo's code run first)
const treeObserver = new MutationObserver(() => {
console.log('Folder tree mutation detected, debouncing sync...');
debouncedSync();
});
treeObserver.observe(folderTree, {
attributes: true,
attributeFilter: ['style', 'class']
});
// Initial sync
syncFolderLayout();
// Monitor the folder tree's parent for display changes
const parentObserver = new MutationObserver(debouncedSync);
const treeParent = folderTree.parentElement;
if (treeParent) {
parentObserver.observe(treeParent, {
childList: false,
attributes: true,
subtree: false
});
}
}
// Media page - item selection
const mediaItems = document.querySelectorAll('.media-item');
mediaItems.forEach(item => {
item.addEventListener('click', function() {
this.style.opacity = '0.7';
setTimeout(() => this.style.opacity = '1', 200);
});
});
}
/**
* Make sidebar responsive
*/
function makeResponsive() {
const sidebar = document.querySelector('.ots-sidebar');
const main = document.querySelector('.ots-main');
if (!sidebar) return;
// Add toggle button for mobile
if (window.innerWidth <= 768) {
sidebar.classList.add('mobile');
}
window.addEventListener('resize', function() {
if (window.innerWidth > 768) {
sidebar.classList.remove('mobile', 'active');
} else {
sidebar.classList.add('mobile');
}
updateSidebarWidth();
updateSidebarGap();
});
}
/**
* Prevent Chart.js errors when chart elements are missing
*/
function initChartSafeguard() {
if (!window.Chart) return;
if (typeof window.Chart.acquireContext === 'function') {
window.Chart.acquireContext = function(item) {
if (!item) return null;
const candidate = item.length ? item[0] : item;
if (candidate && typeof candidate.getContext === 'function') {
return candidate.getContext('2d');
}
return null;
};
return;
}
if (window.Chart.prototype && typeof window.Chart.prototype.acquireContext === 'function') {
window.Chart.prototype.acquireContext = function(item) {
if (!item) return null;
const candidate = item.length ? item[0] : item;
if (candidate && typeof candidate.getContext === 'function') {
return candidate.getContext('2d');
}
return null;
};
}
}
/**
* Enhance tables: wrap in card, add per-table search box, client-side filtering
* Non-destructive: skips tables already enhanced
*/
function enhanceTables() {
const selector = '.ots-content table, .content table, .container table, .card table, table';
const tables = Array.from(document.querySelectorAll(selector));
let counter = 0;
tables.forEach(table => {
// only enhance tables that have a thead and tbody
if (!table || table.classList.contains('modern-table')) return;
if (!table.querySelector('thead') || !table.querySelector('tbody')) return;
counter += 1;
table.classList.add('modern-table');
// Build wrapper structure
const wrapper = document.createElement('div');
wrapper.className = 'modern-table-card';
const controls = document.createElement('div');
controls.className = 'table-controls';
const input = document.createElement('input');
input.type = 'search';
input.placeholder = 'Search…';
input.className = 'table-search-input';
input.setAttribute('aria-label', 'Table search');
input.style.minWidth = '180px';
controls.appendChild(input);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-wrapper';
tableWrapper.style.overflow = 'auto';
// Insert wrapper into DOM in place of the table
const parent = table.parentNode;
parent.replaceChild(wrapper, table);
wrapper.appendChild(controls);
wrapper.appendChild(tableWrapper);
tableWrapper.appendChild(table);
// Simple, light-weight search filtering for this table only
input.addEventListener('input', function (e) {
const term = (e.target.value || '').toLowerCase();
table.querySelectorAll('tbody tr').forEach(tr => {
const text = tr.textContent.toLowerCase();
tr.style.display = term === '' || text.includes(term) ? '' : 'none';
});
});
});
}
/**
* Initialize DataTables for enhanced behavior when available.
* Falls back gracefully if DataTables or jQuery are not present.
*/
function initDataTables() {
if (!window.jQuery) return;
const $ = window.jQuery;
if (!$.fn || !$.fn.dataTable) return;
// Skip Xibo-managed grids to avoid double initialization
if (document.querySelector('.XiboGrid')) return;
$('.modern-table, table').each(function () {
try {
if (this.closest('.XiboGrid')) return;
if (!$.fn.dataTable.isDataTable(this)) {
$(this).DataTable({
responsive: true,
lengthChange: false,
pageLength: 10,
autoWidth: false,
dom: '<"table-controls"f>rt<"table-meta"ip>',
language: { search: '' }
});
}
} catch (err) {
// If initialization fails, ignore and allow fallback enhancer
console.warn('DataTables init failed for table', this, err);
}
});
}
/**
* Initialize light/dark mode toggle
*/
function initThemeToggle() {
const themeToggle = document.getElementById('ots-theme-toggle');
if (!themeToggle) return;
const storedTheme = localStorage.getItem('ots-theme-mode');
const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
const effectiveTheme = storedTheme || (prefersLight ? 'light' : 'dark');
const body = document.body;
const root = document.documentElement;
// Apply stored theme on page load (apply to both <html> and <body>)
if (effectiveTheme === 'light') {
body.classList.add('ots-light-mode');
root.classList.add('ots-light-mode');
updateThemeLabel();
}
// Toggle on click (keep <html> in sync so :root variables reflect mode)
themeToggle.addEventListener('click', function(e) {
e.preventDefault();
const isLight = body.classList.toggle('ots-light-mode');
root.classList.toggle('ots-light-mode', isLight);
localStorage.setItem('ots-theme-mode', isLight ? 'light' : 'dark');
updateThemeLabel();
});
function updateThemeLabel() {
const icon = document.getElementById('ots-theme-icon');
const label = document.getElementById('ots-theme-label');
const isLight = body.classList.contains('ots-light-mode');
if (icon) {
icon.className = isLight ? 'fa fa-sun-o' : 'fa fa-moon-o';
}
if (label) {
label.textContent = isLight ? 'Light Mode' : 'Dark Mode';
}
}
}
/**
* Initialize all features when DOM is ready
*/
function init() {
initSidebarToggle();
initSidebarSectionToggles();
initThemeToggle();
initDropdowns();
initSearch();
initPageInteractions();
initDataTables();
enhanceTables();
makeResponsive();
initChartSafeguard();
updateSidebarWidth();
updateSidebarNavOffset();
updateSidebarGap();
var debouncedUpdate = debounce(function() {
updateSidebarNavOffset();
updateSidebarWidth();
updateSidebarGap();
}, 120);
window.addEventListener('resize', debouncedUpdate);
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

369
display-page.twig Normal file
View File

@@ -0,0 +1,369 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Displays"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("displays.add") %}
<button class="btn btn-success XiboFormButton" title="{% trans "Add a Display via user_code displayed on the Player screen" %}" href="{{ url_for("display.addViaCode.form") }}"> <i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add Display (Code)" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block headContent %}
{# Add page source code bundle ( CSS ) #}
<link rel="stylesheet" href="{{ theme.rootUri() }}dist/pages/display-page.bundle.min.css?v={{ version }}&rev={{revision }}">
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Displays" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="displayView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#filter-general" role="tab" data-toggle="tab">{% trans "General" %}</a></li>
<li class="nav-item"><a class="nav-link" href="#filter-advanced" role="tab" data-toggle="tab">{% trans "Advanced" %}</a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="filter-general">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("displayId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('display', title) }}
{% set title %}{% trans "Status" %}{% endset %}
{% set check %}{% trans "Up to date" %}{% endset %}
{% set cross %}{% trans "Downloading" %}{% endset %}
{% set cloud %}{% trans "Out of date" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: check},
{ optionid: "2", option: cross},
{ optionid: "3", option: cloud}
] %}
{{ inline.dropdown("mediaInventoryStatus", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Logged In?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: yesOption},
{ optionid: "0", option: noOption}
] %}
{{ inline.dropdown("loggedIn", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Authorised?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: yesOption },
{ optionid: "0", option: noOption},
] %}
{{ inline.dropdown("authorised", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "XMR Registered?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 1, option: yesOption},
{ optionid: 0, option: noOption},
] %}
{{ inline.dropdown("xmrRegistered", "single", title, "", options, "optionid", "option") }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
{% set title %}{% trans "Display Group" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("displayGroup.search") },
{ name: "data-filter-options", value: '{"isDisplaySpecific":0}' },
{ name: "data-search-term", value: "displayGroup" },
{ name: "data-id-property", value: "displayGroupId" },
{ name: "data-text-property", value: "displayGroup" },
{ name: "data-initial-key", value: "displayGroupId" },
] %}
{{ inline.dropdown("displayGroupId", "single", title, "", null, "displayGroupId", "displayGroup", helpText, "pagedSelect", "", "", "", attributes) }}
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
{% set title %}{% trans "Display Profile" %}{% endset %}
{{ inline.dropdown("displayProfileId", "single", title, "", [{displayProfileId:null, name:""}]|merge(displayProfiles), "displayProfileId", "name") }}
{% endif %}
{{ inline.hidden("folderId") }}
</div>
<div class="tab-pane" id="filter-advanced">
{% set title %}{% trans "Last Accessed" %}{% endset %}
{{ inline.date("lastAccessed", title) }}
{% set title %}{% trans "Player Type" %}{% endset %}
{% set android %}{% trans "Android" %}{% endset %}
{% set chromeos %}{% trans "ChromeOS" %}{% endset %}
{% set windows %}{% trans "Windows" %}{% endset %}
{% set webos %}{% trans "webOS" %}{% endset %}
{% set sssp %}{% trans "Tizen" %}{% endset %}
{% set linux %}{% trans "Linux" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "android", option: android},
{ optionid: "chromeos", option: chromeos},
{ optionid: "windows", option: windows},
{ optionid: "lg", option: webos},
{ optionid: "sssp", option: sssp},
{ optionid: "linux", option: linux},
] %}
{{ inline.dropdown("clientType", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Player Code" %}{% endset %}
{{ inline.input("clientCode", title) }}
{% set title %}{% trans "Custom ID" %}{% endset %}
{{ inline.input("customId", title) }}
{% set title %}{% trans "Mac Address" %}{% endset %}
{{ inline.input("macAddress", title) }}
{% set title %}{% trans "IP Address" %}{% endset %}
{{ inline.input("clientAddress", title) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set landscape %}{% trans "Landscape" %}{% endset %}
{% set portrait %}{% trans "Portrait" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "landscape", option: landscape},
{ optionid: "portrait", option: portrait}
] %}
{{ inline.dropdown("orientation", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Commercial Licence" %}{% endset %}
{% set licensed %}{% trans "Licensed fully" %}{% endset %}
{% set trial %}{% trans "Trial" %}{% endset %}
{% set notLinceced %}{% trans "Not licenced" %}{% endset %}
{% set notApplicable %}{% trans "Not applicable" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: licensed},
{ optionid: "2", option: trial},
{ optionid: "0", option: notLinceced},
{ optionid: "3", option: notApplicable}
] %}
{{ inline.dropdown("commercialLicence", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Player supported?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 1, option: yesOption},
{ optionid: 0, option: noOption},
] %}
{{ inline.dropdown("isPlayerSupported", "single", title, "", options, "optionid", "option") }}
</div>
</div>
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{{ "Open / Close Folder Search options"|trans }}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div class="map-controller d-none pl-1">
<button type="button" id="map_button" class="btn btn-primary" title="{{ "Map"|trans }}"><i class="fa fa-map"></i></button>
</div>
<div class="list-controller d-none pl-1">
<button type="button" id="list_button" class="btn btn-primary" title="{{ "List"|trans }}"><i class="fa fa-list"></i></button>
</div>
<div id="datatable-container">
<div class="XiboData card py-3">
<table id="displays" class="table table-striped" data-content-type="display" data-content-id-name="displayId" data-state-preference-name="displayGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Display" %}</th>
<th>{% trans "Display Type" %}</th>
<th>{% trans "Address" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Authorised?" %}</th>
<th>{% trans "Current Layout" %}</th>
<th>{% trans "Storage Available" %}</th>
<th>{% trans "Storage Total" %}</th>
<th>{% trans "Storage Free %" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Orientation" %}</th>
<th>{% trans "Resolution" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Default Layout" %}</th>
<th>{% trans "Interleave Default" %}</th>
<th>{% trans "Email Alert" %}</th>
<th>{% trans "Logged In" %}</th>
<th>{% trans "Last Accessed" %}</th>
<th>{% trans "Display Profile" %}</th>
<th>{% trans "Version" %}</th>
<th>{% trans "Supported?" %}</th>
<th>{% trans "Device Name" %}</th>
<th>{% trans "IP Address" %}</th>
<th>{% trans "Mac Address" %}</th>
<th>{% trans "Timezone" %}</th>
<th>{% trans "Languages" %}</th>
<th>{% trans "Latitude" %}</th>
<th>{% trans "Longitude" %}</th>
<th>{% trans "Screen shot?" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "CMS Transfer?" %}</th>
<th>{% trans "Bandwidth Limit" %}</th>
<th>{% trans "Last Command" %}</th>
<th>{% trans "XMR Registered" %}</th>
<th>{% trans "Commercial Licence" %}</th>
<th>{% trans "Remote" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Screen Size" %}</th>
<th>{% trans "Is Mobile?" %}</th>
<th>{% trans "Outdoor?" %}</th>
<th>{% trans "Reference 1" %}</th>
<th>{% trans "Reference 2" %}</th>
<th>{% trans "Reference 3" %}</th>
<th>{% trans "Reference 4" %}</th>
<th>{% trans "Reference 5" %}</th>
<th>{% trans "Custom ID" %}</th>
<th>{% trans "Cost Per Play" %}</th>
<th>{% trans "Impressions Per Play" %}</th>
<th>{% trans "Created Date" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Faults?" %}</th>
<th>{% trans "OS Version" %}</th>
<th>{% trans "OS SDK" %}</th>
<th>{% trans "Manufacturer" %}</th>
<th>{% trans "Brand" %}</th>
<th>{% trans "Model" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<!-- Map -->
<div class="row">
<div class="col-sm-12">
<div class="map-legend" style="display:none; position: absolute; z-index: 500; right: 20px; top: 10px;">
<div class="display-map-legend" style="font-size: 12px;">
<div>Logged in</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-green-check.png'/> - Up to date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-yellow-check.png'/> - Out of date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-red-check.png'/> - Downloading/Unknown</div>
</br>
<div>Logged out</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-green-cross.png'/> - Up to date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-yellow-cross.png'/> - Out of date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-red-cross.png'/> - Downloading/Unknown</div>
</div>
</div>
<div id="display-map" data-displays-url="{{ url_for("display.map") }}">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables and translations #}
<script type="text/javascript" nonce="{{ cspNonce }}" defer>
{# JS variables #}
var publicPath = "{{ theme.rootUri() }}";
var displaySearchURL = "{{ url_for('display.search') }}";
var layoutSearchURL = "{{ url_for('layout.search') }}";
var mapConfig = {{ mapConfig| json_encode | raw }};
var playerVersionSupport = "{{playerVersion}}";
var folderViewEnabled = "{{ currentUser.featureEnabled('folder.view') }}";
var taggingEnabled = "{{ currentUser.featureEnabled('tag.tagging') }}";
var showThumbnailColumn = "{{ currentUser.getOptionValue('showThumbnailColumn', 1) }}";
var SHOW_DISPLAY_AS_VNCLINK = "{{ settings.SHOW_DISPLAY_AS_VNCLINK }}";
var SHOW_DISPLAY_AS_VNC_TGT = "{{ settings.SHOW_DISPLAY_AS_VNC_TGT }}";
{# Custom translations #}
var displayPageTrans = {
back: "{% trans "Back" %}",
yes: "{% trans "Yes" %}",
no: "{% trans "No" %}",
daysOfTheWeek: {
monday: "{% trans "Monday" %}",
tuesday: "{% trans "Tuesday" %}",
wednesday: "{% trans "Wednesday" %}",
thursday: "{% trans "Thursday" %}",
friday: "{% trans "Friday" %}",
saturday: "{% trans "Saturday" %}",
sunday: "{% trans "Sunday" %}",
},
playerStatusWindow: "{% trans "Player Status Window" %}",
VNCtoThisDisplay: "{% trans "VNC to this Display" %}",
TeamViewertoThisDisplay: "{% trans "TeamViewer to this Display" %}",
WebkeytoThisDisplay: "{% trans "Webkey to this Display" %}",
};
</script>
{# Add page source code bundle ( JS ) #}
<script src="{{ theme.rootUri() }}dist/leaflet.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/pages/display-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{% endblock %}

173
tmp/xibo-campaign-page.twig Normal file
View File

@@ -0,0 +1,173 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Campaigns"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("campaign.add") %}
<button class="btn btn-success XiboFormButton" title="{% trans "Add a new Campaign" %}" href="{{ url_for("campaign.add.form") }}"> <i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add Campaign" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Campaigns" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="campaignView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set title %}{% trans "Layouts" %}{% endset %}
{% set values = [{id: 0, value: ""}, {id: 2, value: "Yes"}, {id: 1, value: "No"}] %}
{{ inline.dropdown("hasLayouts", "single", title, 0, values, "id", "value") }}
{{ inline.hidden("folderId") }}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title, layoutId) }}
{% if currentUser.featureEnabled('ad.campaign') %}
{% set title %}{% trans "Type" %}{% endset %}
{% set options = [
{ id: null, name: "" },
{ id: "list", name: "Layout list"|trans },
{ id: "ad", name: "Ad Campaign"|trans }
] %}
{{ inline.dropdown("type", "single", title, "both", options, "id", "name", helpText) }}
{% endif %}
{% set title %}{% trans "Cycle Based Playback" %}{% endset %}
{% set enabled %}{% trans "Enabled" %}{% endset %}
{% set disabled %}{% trans "Disabled" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 0, option: disabled},
{ optionid: 1, option: enabled}
] %}
{{ inline.dropdown("cyclePlaybackEnabled", "single", title, "", options, "optionid", "option") }}
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3">
<table id="campaigns" class="table table-striped" data-content-type="campaign" data-content-id-name="campaignId" data-state-preference-name="campaignGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "Name" %}</th>
{% if currentUser.featureEnabled('ad.campaign') %}
<th>{% trans "Type" %}</th>
<th>{% trans "Start Date" %}</th>
<th>{% trans "End Date" %}</th>
{% endif %}
<th>{% trans "# Layouts" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Duration" %}</th>
<th>{% trans "Cycle based Playback" %}</th>
<th>{% trans "Play Count" %}</th>
{% if currentUser.featureEnabled('ad.campaign') %}
<th>{% trans "Target Type" %}</th>
<th>{% trans "Target" %}</th>
<th>{% trans "Plays" %}</th>
<th>{% trans "Spend" %}</th>
<th>{% trans "Impressions" %}</th>
{% endif %}
<th>{% trans "Ref 1" %}</th>
<th>{% trans "Ref 2" %}</th>
<th>{% trans "Ref 3" %}</th>
<th>{% trans "Ref 4" %}</th>
<th>{% trans "Ref 5" %}</th>
<th>{% trans "Created At" %}</th>
<th>{% trans "Modified At" %}</th>
<th>{% trans "Modified By" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables and translations #}
<script type="text/javascript" nonce="{{ cspNonce }}" defer>
{# JS variables #}
var campaignSearchURL = "{{ url_for('campaign.search') }}";
var layoutSearchURL = "{{ url_for('layout.search') }}";
var folderViewEnabled = "{{ currentUser.featureEnabled('folder.view') }}";
var adCampaignEnabled = "{{ currentUser.featureEnabled('ad.campaign') }}";
var taggingEnabled = "{{ currentUser.featureEnabled('tag.tagging') }}";
{# Custom translations #}
var campaignPageTrans = {
list: "{% trans "List" %}",
ad: "{% trans "Ad" %}",
plays: "{% trans "Plays" %}",
budget: "{% trans "Budget" %}",
impressions: "{% trans "Impressions" %}",
};
</script>
{# Add page source code bundle #}
<script src="{{ theme.rootUri() }}dist/pages/campaign-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{% endblock %}

View File

@@ -0,0 +1 @@
404: Not Found

584
tmp/xibo-dataset-page.twig Normal file
View File

@@ -0,0 +1,584 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% import "forms.twig" as forms %}
{% block title %}{{ "DataSets"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("dataset.add") %}
<button class="btn btn-success XiboFormButton btns" title="{% trans "Add a new DataSet" %}" href="{{ url_for("dataSet.add.form") }}"> <i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add DataSet" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "DataSets" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="dataSetView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline" onsubmit="return false">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('dataSet', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{% set helpText %}{% trans "Show items which match the provided code" %}{% endset %}
{{ inline.input("code", title, "", helpText) }}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3">
<table id="datasets" class="table table-striped" data-state-preference-name="dataSetGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Remote?" %}</th>
<th>{% trans "Real time?" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Last Sync" %}</th>
<th>{% trans "Data Last Modified" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#datasets").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 0, "asc"]],
ajax: {
"url": "{{ url_for("dataSet.search") }}",
"data": function(d) {
$.extend(d, $("#datasets").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "dataSetId", responsivePriority: 2 },
{ "data": "dataSet", "render": dataTableSpacingPreformatted, responsivePriority: 2 },
{ "data": "description", responsivePriority: 4 },
{ "data": "code", responsivePriority: 3 },
{
"data": "isRemote",
responsivePriority: 3,
"render": dataTableTickCrossColumn
},
{
data: 'isRealTime',
responsivePriority: 3,
render: dataTableTickCrossColumn,
},
{ "data": "owner", responsivePriority: 3 },
{
"data": "groupsWithPermissions",
responsivePriority: 3,
"render": dataTableCreatePermissions
},
{
"data": "lastSync",
responsivePriority: 4,
"render": dataTableDateFromUnix
},
{
"data": "lastDataEdit",
responsivePriority: 4,
"render": dataTableDateFromUnix
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', function(e, settings) {
dataTableDraw(e, settings);
// Upload form
$(".dataSetImportForm").click(function(e) {
e.preventDefault();
var template = Handlebars.compile($("#template-dataset-upload").html());
var data = table.row($(this).closest("tr")).data();
var columns = [];
var i = 1;
$.each(data.columns, function (index, element) {
if (element.dataSetColumnTypeId === 1) {
element.index = i;
columns.push(element);
i++;
}
});
// Handle bars and open a dialog
bootbox.dialog({
message: template({
trans: {
addFiles: "{% trans "Add CSV Files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}",
processing: "{% trans "Processing..." %}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ libraryUpload.validExt }}",
utf8Message: "{% trans "If the CSV file contains non-ASCII characters please ensure the file is UTF-8 encoded" %}"
},
columns: columns
}),
title: "{% trans "CSV Import" %}",
size: 'large',
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function() {
table.ajax.reload();
XiboDialogClose();
}
}
}
}).on('shown.bs.modal', function() {
// Configure the upload form
var url = "{{ url_for("dataSet.import", {id: ':id'}) }}".replace(":id", data.dataSetId);
var form = $(this).find("form");
var refreshSessionInterval;
// Initialize the jQuery File Upload widget:
form.fileupload({
url: url,
disableImageResize: true
});
// Upload server status check for browsers with CORS support:
if ($.support.cors) {
$.ajax({
url: url,
type: 'HEAD'
}).fail(function () {
$('<span class="alert alert-error"/>')
.text('Upload server currently unavailable - ' + new Date())
.appendTo(form);
});
}
// Enable iframe cross-domain access via redirect option:
form.fileupload(
'option',
'redirect',
window.location.href.replace(
/\/[^\/]*$/,
'/cors/result.html?%s'
)
);
form.bind('fileuploadsubmit', function (e, data) {
var inputs = data.context.find(':input');
if (inputs.filter('[required][value=""]').first().focus().length) {
return false;
}
data.formData = inputs.serializeArray().concat(form.serializeArray());
inputs.filter("input").prop("disabled", true);
}).bind('fileuploadstart', function (e, data) {
// Show progress data
form.find('.fileupload-progress .progress-extended').show();
form.find('.fileupload-progress .progress-end').hide();
if (form.fileupload("active") <= 0)
refreshSessionInterval = setInterval("XiboPing('" + pingUrl + "?refreshSession=true')", 1000 * 60 * 3);
return true;
}).bind('fileuploaddone', function (e, data) {
if (refreshSessionInterval != null && form.fileupload("active") <= 0)
clearInterval(refreshSessionInterval);
}).bind('fileuploadprogressall', function (e, data) {
// Hide progress data and show processing
if(data.total > 0 && data.loaded == data.total) {
form.find('.fileupload-progress .progress-extended').hide();
form.find('.fileupload-progress .progress-end').show();
}
}).bind('fileuploadadded fileuploadcompleted fileuploadfinished', function (e, data) {
// Get uploaded and downloaded files and toggle Done button
var filesToUploadCount = form.find('tr.template-upload').length;
var $button = form.parents('.modal:first').find('button.btn-bb-main');
if(filesToUploadCount == 0) {
$button.removeAttr('disabled');
} else {
$button.attr('disabled', 'disabled');
}
});
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#datasets_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
function dataSetFormOpen(dialog) {
// Bind the remote dataset test button
$(dialog).find("#dataSetRemoteTestButton").on('click', function() {
var $form = $(dialog).find("form");
XiboRemoteRequest("{{ url_for("dataSet.test.remote") }}", $form.serializeObject(), function(response) {
if (!response.success || !$.trim(response.data.entries)) {
response.data = response.message;
}
$("#datasetRemoteTestRequestResult").html('<pre style="height: 300px; overflow: scroll">' + JSON.stringify(response.data, null, 3) + '</pre>');
});
});
// Set up some dependencies between the isRemote checkbox and the tabs related to remote datasets
onRemoteFieldChanged(dialog);
// show data source dropdown if real time is checked
onIsRealTimeFieldChanged(dialog);
$(dialog).find("#isRemote").on('change', function() {
onRemoteFieldChanged(dialog);
});
$(dialog).find("#isRealTime").on('change', function() {
onIsRealTimeFieldChanged(dialog);
});
// Auth field
onAuthenticationFieldChanged(dialog);
$(dialog).find("#authentication").on('change', function() {
onAuthenticationFieldChanged(dialog);
});
// remote DataSet source
onSourceFieldChanged(dialog);
$(dialog).find('#sourceId').on('change', function() {
onSourceFieldChanged(dialog);
});
// Validate form manually because
// uri field depends on isRemote being checked
if (forms != undefined) {
const $form = $(dialog).find('form');
forms.validateForm(
$form, // form
$form.parent(), // container
{
submitHandler: XiboFormSubmit,
rules: {
uri: {
required: function(element) {
return $form.find('#isRemote').is(':checked')
},
},
},
},
);
}
}
function onIsRealTimeFieldChanged(dialog) {
var isRealTime = $(dialog).find("#isRealTime").is(":checked");
var dataSourceField = $(dialog).find("#dataSourceField");
var dataConnectorSource = $(dialog).find("#dataConnectorSource");
if (isRealTime) {
// show and enable data connector source
dataSourceField.removeClass("d-none");
dataConnectorSource.prop('disabled', false)
} else {
// hide and disable data connector source
dataSourceField.addClass("d-none");
dataConnectorSource.prop('disabled', true)
}
}
function onRemoteFieldChanged(dialog) {
var isRemote = $(dialog).find("#isRemote").is(":checked");
var $remoteTabs = $(dialog).find(".tabForRemoteDataSet");
if (isRemote) {
$remoteTabs.removeClass("d-none");
} else {
$remoteTabs.addClass("d-none");
}
}
function onAuthenticationFieldChanged(dialog) {
var authentication = $(dialog).find("#authentication").val();
var $authFieldUserName = $(dialog).find(".auth-field-username");
var $authFieldPassword = $(dialog).find(".auth-field-password");
if (authentication === "none") {
$authFieldUserName.addClass("d-none");
$authFieldPassword.addClass("d-none");
} else if (authentication === "bearer") {
$authFieldUserName.addClass("d-none");
$authFieldPassword.removeClass("d-none");
} else {
$authFieldUserName.removeClass("d-none");
$authFieldPassword.removeClass("d-none");
}
}
function onSourceFieldChanged(dialog) {
var sourceId = $(dialog).find('#sourceId').val();
var $jsonSource = $(dialog).find(".json-source-field");
var $csvSource = $(dialog).find(".csv-source-field");
if (sourceId == 1) {
$jsonSource.removeClass('d-none');
$csvSource.addClass('d-none');
} else {
$jsonSource.addClass('d-none');
$csvSource.removeClass('d-none');
}
}
function deleteMultiSelectFormOpen(dialog) {
{% set message = 'Delete any associated data?' %}
var $input = $('<input type=checkbox id="deleteData" name="deleteData"> {{ message|trans|e }} </input>');
$input.on('change', function() {
dialog.data().commitData = {deleteData: $(this).val()};
});
$(dialog).find('.modal-body').append($input);
}
</script>
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-dataset-upload">
<form class="form-horizontal" method="post" enctype="multipart/form-data" data-max-file-size="{{ upload.maxSize }}" data-accept-file-types="/(\.|\/)csv/i">
<div class="row fileupload-buttonbar">
<div class="card p-3 mb-3 bg-light">
{{ upload.maxSizeMessage }} <br>
{{ upload.utf8Message }}
</div>
<div class="col-md-7">
<!-- The fileinput-button span is used to style the file input field as button -->
<span class="btn btn-success fileinput-button">
<i class="fa fa-plus"></i>
<span>{{ trans.addFiles }}</span>
<input type="file" name="files">
</span>
<button type="submit" class="btn btn-primary start">
<i class="fa fa-upload"></i>
<span>{{ trans.startUpload }}</span>
</button>
<button type="reset" class="btn btn-warning cancel">
<i class="fa fa-ban"></i>
<span>{{ trans.cancelUpload }}</span>
</button>
<!-- The loading indicator is shown during file processing -->
<span class="fileupload-loading"></span>
</div>
<!-- The global progress information -->
<div class="col-md-4 fileupload-progress fade">
<!-- The global progress bar -->
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-striped active" role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width:0%;">
<div class="sr-only"></div>
</div>
</div>
<!-- The extended global progress information -->
<div class="progress-extended">&nbsp;</div>
<!-- Processing info container -->
<div class="progress-end" style="display:none;">{{ trans.processing }}</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
{% endverbatim %}
{% set title %}{% trans "Overwrite existing data?" %}{% endset %}
{% set helpText %}{% trans "Erase all content in this DataSet and overwrite it with the new content in this import." %}{% endset %}
{{ forms.checkbox("overwrite", title, "", helpText) }}
{% set title %}{% trans "Ignore first row?" %}{% endset %}
{% set helpText %}{% trans "Ignore the first row? Useful if the CSV has headings." %}{% endset %}
{{ forms.checkbox("ignorefirstrow", title, "", helpText) }}
{% set message %}{% trans "In the fields below please enter the column number in the CSV file that corresponds to the Column Heading listed. This should be done before Adding the file." %}{% endset %}
{{ forms.message(message) }}
{% verbatim %}
{{#each columns}}
<div class="form-group row">
<label class="col-sm-2 control-label" for="csvImport_{{dataSetColumnId}}">{{heading}}</label>
<div class="col-sm-10">
<input class="form-control" name="csvImport_{{dataSetColumnId}}" type="number" id="csvImport_{{dataSetColumnId}}" value="{{ index }}" />
</div>
</div>
{{/each}}
</div>
</div>
<!-- The table listing the files available for upload/download -->
<table role="presentation" class="table table-striped"><tbody class="files"></tbody></table>
</form>
</script>
<!-- The template to display files available for upload -->
<script id="template-dataset-upload" type="text/x-tmpl">
{% for (var i=0, file; file=o.files[i]; i++) { %}
<tr class="template-upload">
<td>
<span class="fileupload-preview"></span>
</td>
<td class="title">
{% if (file.error) { %}
<div><span class="label label-danger">{%=file.error%}</span></div>
{% } %}
{% if (!file.error) { %}
<label for="name[]"><input name="name[]" type="text" id="name" value="" /></label>
{% } %}
</td>
<td>
<p class="size">{%=o.formatFileSize(file.size)%}</p>
{% if (!o.files.error) { %}
<div class="progress">
<div class="progress-bar progress-bar-striped active" role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width:0%;">
<div class="sr-only"></div>
</div>
</div>
</div>
{% } %}
</td>
<td class="btn-group">
{% if (!o.files.error && !i && !o.options.autoUpload) { %}
<button class="btn btn-primary start">
<i class="fa fa-upload"></i>
</button>
{% } %}
{% if (!i) { %}
<button class="btn btn-warning cancel">
<i class="fa fa-ban"></i>
</button>
{% } %}
</td>
</tr>
{% } %}
</script>
<!-- The template to display files available for download -->
<script id="template-dataset-download" type="text/x-tmpl">
{% for (var i=0, file; file=o.files[i]; i++) { %}
<tr class="template-download">
<td>
<p class="name" id="{%=file.storedas%}" status="{% if (file.error) { %}error{% } %}">
{%=file.name%}
</p>
{% if (file.error) { %}
<div><span class="label label-danger">{%=file.error%}</span></div>
{% } %}
</td>
<td>
<span class="size">{%=o.formatFileSize(file.size)%}</span>
</td>
</tr>
{% } %}
</script>
{% endverbatim %}
{% endblock %}

248
tmp/xibo-daypart-page.twig Normal file
View File

@@ -0,0 +1,248 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Dayparting"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("daypart.add") %}
<button class="btn btn-success XiboFormButton" href="{{ url_for("daypart.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add Daypart" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Dayparting" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Retired" %}{% endset %}
{% set option1 = "Yes"|trans %}
{% set option2 = "No"|trans %}
{% set values = [{id: 1, value: option1}, {id: 0, value: option2}] %}
{{ inline.dropdown("isRetired", "single", title, 0, values, "id", "value") }}
</form>
</div>
</div>
<div class="XiboData card pt-3">
<table id="dayparts" class="table table-striped" data-state-preference-name="daypartGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Start Time" %}</th>
<th>{% trans "End Time" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#dayparts").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("daypart.search") }}",
"data": function(d) {
$.extend(d, $("#dayparts").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "name", "render": dataTableSpacingPreformatted , responsivePriority: 2},
{ "data": "description" },
{ "data": "startTime" },
{ "data": "endTime" },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#dayparts_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function dayPartFormOpen(dialog) {
// Render a set of exceptions
$exceptions = $(dialog).find("#dayPartExceptions");
// Days of the week translations
var daysOfTheWeek = [
{ day: "Mon", title: "{% trans "Monday" %}" },
{ day: "Tue", title: "{% trans "Tuesday" %}" },
{ day: "Wed", title: "{% trans "Wednesday" %}" },
{ day: "Thu", title: "{% trans "Thursday" %}" },
{ day: "Fri", title: "{% trans "Friday" %}" },
{ day: "Sat", title: "{% trans "Saturday" %}" },
{ day: "Sun", title: "{% trans "Sunday" %}" }
];
// Compile the handlebars template
var exceptionsTemplate = Handlebars.compile($("#dayPartExceptionsTemplate").html());
if (dialog.data().extra.exceptions.length == 0) {
// Contexts for template
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: "fa-plus",
exceptionDay: "",
exceptionStart: "",
exceptionEnd: "",
fieldId: 0
};
// Append
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
} else {
// For each of the existing exceptions, create form components
var i = 0;
$.each(dialog.data().extra.exceptions, function (index, field) {
i++;
// call the template
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: ((i == 1) ? "fa-plus" : "fa-minus"),
exceptionDay: field.day,
exceptionStart: field.start,
exceptionEnd: field.end,
fieldId: i
};
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
});
}
// Nabble the resulting buttons
$exceptions.on("click", "button", function (e) {
e.preventDefault();
// find the gylph
if ($(this).find("i").hasClass("fa-plus")) {
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: "fa-minus",
exceptionDay: "",
exceptionStart: "",
exceptionEnd: "",
fieldId: $exceptions.find('.form-group').length + 1
};
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
} else {
// Remove this row
$(this).closest(".form-group").remove();
}
});
// check if we already have this day in exceptions array, if so remove the row with a message.
$exceptions.on("change", "select", function() {
var selectedDays = [];
$('select').not('#' + $(this).attr('id')).each(function(i) {
selectedDays.push($(this).val());
});
if (selectedDays.includes(this.value)) {
toastr.error(translations.dayPartExceptionErrorMessage);
// Remove this row
$(this).closest(".form-group").remove();
}
})
}
// Equals helper for the templates below
Handlebars.registerHelper('eq', function(v1, v2, opts) {
if (v1 === v2) {
return opts.fn(this);
} else {
return opts.inverse(this);
}
});
</script>
{% verbatim %}
<script type="text/x-handlebars-template" id="dayPartExceptionsTemplate">
<div class="form-group row">
<div class="col-3">
<select class="form-control" name="exceptionDays[]" id="exceptionDays_{{fieldId}}">
<option value=""></option>
{{#each daysOfWeek}}
<option value="{{ day }}" {{#eq day ../exceptionDay}}selected{{/eq}}>{{ title }}</option>
{{/each}}
</select>
</div>
<div class="col-3">
{% endverbatim %}
{{ inline.time("exceptionStartTimes[]", "", "{{ exceptionStart }}" ) }}
{% verbatim %}
</div>
<div class="col-3">
{% endverbatim %}
{{ inline.time("exceptionEndTimes[]", "", "{{ exceptionEnd }}" ) }}
{% verbatim %}
</div>
<div class="col-1">
<button class="btn btn-white"><i class="fa {{ buttonGlyph }}"></i></button>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

523
tmp/xibo-layout-page.twig Normal file
View File

@@ -0,0 +1,523 @@
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Layouts"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("layout.add") %}
<button class="btn btn-success layout-add-button"
title="{% trans "Add a new Layout and jump to the layout editor." %}"
href="{{ url_for("layout.add") }}">
<i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add Layout" %}
</button>
<button class="btn btn-info" id="layoutUploadForm" title="{% trans "Import a Layout from a ZIP file." %}" href="#"> <i class="fa fa-cloud-download" aria-hidden="true"></i> {% trans "Import" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Layouts" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-type="layout" data-grid-name="layoutView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab" aria-selected="true"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab" aria-selected="false"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline d-block">
<div class="tab-content">
<div class="tab-pane active" id="general-filter" role="tabpanel">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("campaignId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('layout', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input('codeLike', title) }}
{% if currentUser.featureEnabled("displaygroup.view") %}
{% set title %}{% trans "Display Group" %}{% endset %}
{% set helpText %}{% trans "Show Layouts active on the selected Display / Display Group" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("displayGroup.search") },
{ name: "data-filter-options", value: '{"isDisplaySpecific":-1}' },
{ name: "data-search-term", value: "displayGroup" },
{ name: "data-id-property", value: "displayGroupId" },
{ name: "data-text-property", value: "displayGroup" },
{ name: "data-initial-key", value: "displayGroupId" },
] %}
{{ inline.dropdown("activeDisplayGroupId", "single", title, "", null, "displayGroupId", "displayGroup", helpText, "pagedSelect", "", "", "", attributes) }}
{% endif %}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Owner User Group" %}{% endset %}
{% set helpText %}{% trans "Show items owned by users in the selected User Group." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("group.search") },
{ name: "data-search-term", value: "group" },
{ name: "data-id-property", value: "groupId" },
{ name: "data-text-property", value: "group" },
{ name: "data-initial-key", value: "userGroupId" },
] %}
{{ inline.dropdown("ownerUserGroupId", "single", title, "", null, "groupId", "group", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Landscape"|trans %}
{% set option3 = "Portrait"|trans %}
{% set values = [{id: '', value: option1}, {id: 'landscape', value: option2}, {id: 'portrait', value: option3}] %}
{{ inline.dropdown("orientation", "single", title, '', values, "id", "value") }}
{{ inline.hidden("folderId") }}
</div>
<div class="tab-pane" id="advanced-filter" role="tabpanel">
{% set title %}{% trans "Retired" %}{% endset %}
{% set option1 = "No"|trans %}
{% set option2 = "Yes"|trans %}
{% set values = [{id: 0, value: option1}, {id: 1, value: option2}] %}
{{ inline.dropdown("retired", "single", title, 0, values, "id", "value") }}
{% set title %}{% trans "Show" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Only Used"|trans %}
{% set option3 = "Only Unused"|trans %}
{% set values = [{id: 1, value: option1}, {id: 2, value: option2}, {id: 3, value: option3}] %}
{{ inline.dropdown("layoutStatusId", "single", title, 1, values, "id", "value") }}
{% set title %}{% trans "Description" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "1st line"|trans %}
{% set option3 = "Widget List"|trans %}
{% set values = [{id: 1, value: option1}, {id: 2, value: option2}, {id: 3, value: option3}] %}
{{ inline.dropdown("showDescriptionId", "single", title, 2, values, "id", "value") }}
{% if currentUser.featureEnabled("library.view") %}
{% set title %}{% trans "Media" %}{% endset %}
{{ inline.input("mediaLike", title) }}
{% endif %}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title) }}
{% set title %}{% trans "Modified Since" %}{% endset %}
{{ inline.date("modifiedSinceDt", title) }}
</div>
</div>
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3">
<table id="layouts" class="table table-striped responsive nowrap" data-content-type="layout" data-content-id-name="layoutId" data-state-preference-name="layoutGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Duration" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Orientation" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Valid?" %}</th>
<th>{% trans "Stats?" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Layout ID" %}</th>
<th>{% trans "Code" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#layouts").DataTable({
language: dataTablesLanguage,
lengthMenu: [10, 25, 50, 100, 250, 500],
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
dataType: 'json',
order: [[1, "asc"]],
ajax: {
url: "{{ url_for("layout.search") }}",
data: function (d) {
$.extend(d, $("#layouts").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
columns: [
{"data": "campaignId", responsivePriority: 1},
{
"data": "layout",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"name": "publishedStatus",
responsivePriority: 2,
"data": function (data, type) {
if (data.publishedDate != null) {
var now = moment();
var published = moment(data.publishedDate);
var differenceMinutes = published.diff(now, 'minutes');
var momentDifference = moment(now).to(published);
if (differenceMinutes < -5) {
return data.publishedStatus.concat(" - ", translations.publishedStatusFailed);
} else {
return data.publishedStatus.concat(" - ", translations.publishedStatusFuture + " " + momentDifference);
}
} else {
return data.publishedStatus;
}
}
},
{
"name": "description",
"data": null,
responsivePriority: 10,
"render": {"_": "description", "display": "descriptionFormatted", "sort": "description"}
},
{
"name": "duration",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.duration;
return dataTableTimeFromSeconds(data.duration, type);
}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
responsivePriority: 3,
"data": dataTableCreateTags
},{% endif %}
{ data: 'orientation', responsivePriority: 10, visible: false},
{
responsivePriority: 5,
data: 'thumbnail',
render: function(data, type, row) {
if (type !== 'display') {
return row.layoutId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
} else {
var addUrl = '{{ url_for("layout.thumbnail.add", {id: ":id"}) }}'.replace(':id', row.layoutId);
return '<a class="img-replace generate-layout-thumbnail" data-type="image" href="' + addUrl + '">' +
'<img class="img-fluid" src="{{ theme.uri("img/thumbs/placeholder.png") }}" alt="{{ "Add Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"name": "status",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.status;
var icon = "";
if (data.status == 1)
icon = "fa-check";
else if (data.status == 2)
icon = "fa-exclamation";
else if (data.status == 3)
icon = "fa-cogs";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.statusDescription) + ((data.statusMessage == null) ? "" : " - " + (data.statusMessage)) + '"></span>';
}
},
{
"name": "enableStat",
responsivePriority: 4,
"data": function (data) {
var icon = "";
if (data.enableStat == 1)
icon = "fa-check";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.enableStatDescription) + '"></span>';
}
},
{
"data": "createdDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
data: "modifiedDt",
responsivePriority: 6,
render: dataTableDateFromIso,
visible: true
},
{
data: "layoutId",
visible: false,
responsivePriority: 4
},
{"data": "code", "visible":false, responsivePriority: 4},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#layouts").closest(".XiboGrid").find(".FilterDiv form") }, dataTableCreateTagEvents);
table.on('draw', function(e, settings) {
$('#' + e.target.id + ' .generate-layout-thumbnail').on('click', function(e) {
e.preventDefault();
var $anchor = $(this);
$.ajax({
url: $anchor.attr('href'),
method: 'POST',
success: function() {
$anchor.find('img').attr('src', $anchor.attr('href'));
$anchor.removeClass('generate-layout-thumbnail').attr('data-toggle', 'lightbox');
}
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#layouts_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function() {
table.ajax.reload();
});
// Bind to the layout add button
$('button.layout-add-button').on('click', function() {
let currentWorkingFolderId =
$("#layouts")
.closest(".XiboGrid")
.find(".FilterDiv form")
.find('#folderId').val()
// Submit the URL provided as a POST request.
$.ajax({
type: 'POST',
url: $(this).attr('href'),
cache: false,
data : {folderId : currentWorkingFolderId},
dataType: 'json',
success: function(response, textStatus, error) {
if (response.success && response.id) {
XiboRedirect('{{ url_for("layout.designer", {id: ':id'}) }}'.replace(':id', response.id));
} else {
if (response.login) {
LoginBox(response.message);
} else {
SystemMessage(response.message ?? '{{ "Unknown Error"|trans }}', false);
}
}
},
error: function(xhr, textStatus, errorThrown) {
SystemMessage(xhr.responseText, false);
},
});
});
});
$("#layoutUploadForm").click(function(e) {
e.preventDefault();
var currentWorkingFolderId = $('#folderId').val();
// Open the upload dialog with our options.
openUploadForm({
url: "{{ url_for("layout.import") }}",
title: "{{ "Upload Layout"|trans }}",
videoImageCovers: false,
buttons: {
main: {
label: "{{ "Done"|trans }}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
layoutImport: true,
updateInAllChecked: {% if settings.LIBRARY_MEDIA_UPDATEINALL_CHECKB == 1 %}true{% else %}false{% endif %},
deleteOldRevisionsChecked: {% if settings.LIBRARY_MEDIA_DELETEOLDVER_CHECKB == 1 %}true{% else %}false{% endif %},
trans: {
addFiles: "{{ "Add Layout Export ZIP Files"|trans }}",
startUpload: "{{ "Start Import"|trans }}",
cancelUpload: "{{ "Cancel Import"|trans }}",
replaceExistingMediaMessage: "{{ "Replace Existing Media?"|trans }}",
importTagsMessage: "{{ "Import Tags?"|trans }}",
useExistingDataSetsMessage: "{{ "Use existing DataSets matched by name?"|trans }}",
dataSetDataMessage: "{{ "Import DataSet Data?"|trans }}",
fallbackMessage: "{{ "Import Widget Fallback Data?"|trans }}",
selectFolder: "{{ "Select Folder"|trans }}",
selectFolderTitle: "{{ "Change Current Folder location"|trans }}",
selectedFolder: "{{ "Current Folder"|trans }}:",
selectedFolderTitle: "{{ "Upload files to this Folder"|trans }}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "zip"
},
currentWorkingFolderId: currentWorkingFolderId,
folderSelector: true
},
formOpenedEvent: function () {
// Configure the active behaviour of the checkboxes
$("#useExistingDataSets").on("click", function () {
$("#importDataSetData").prop("disabled", ($(this).is(":checked")));
});
},
uploadDoneEvent: function (data) {
XiboDialogClose();
table.ajax.reload();
}
});
});
function layoutExportFormSubmit() {
var $form = $("#layoutExportForm");
window.location = $form.attr("action") + "?" + $form.serialize();
setTimeout(function() {
XiboDialogClose();
}, 1000);
}
function assignLayoutToCampaignFormSubmit() {
var form = $("#layoutAssignCampaignForm");
var url = form.prop("action").replace(":id", form.find("#campaignId").val());
$.ajax({
type: form.attr("method"),
url: url,
data: {layoutId: form.data().layoutId},
cache: false,
dataType:"json",
success: XiboSubmitResponse
});
}
function setEnableStatMultiSelectFormOpen(dialog) {
var $input = $('<input type=checkbox id="enableStat" name="enableStat"> {{ "Enable Stats Collection?"|trans }} </input>');
var $helpText = $('<span class="help-block">{{ "Check to enable the collection of Proof of Play statistics for the selected items."|trans }}</span>');
$input.on('change', function() {
dialog.data().commitData = {enableStat: $(this).val()};
});
$(dialog).find('.modal-body').append($input);
$(dialog).find('.modal-body').append($helpText);
}
function layoutPublishFormOpen() {
// Nothing to do here, but we use the same form on the layout designer and have a callback registered there
}
function layoutEditFormSaved() {
// Nothing to do here.
}
</script>
{% endblock %}

View File

@@ -0,0 +1,194 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Menu Boards"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("menuBoard.add") %}
<button class="btn btn-success XiboFormButton" title="{% trans "Add a new Menu Board" %}" href="{{ url_for("menuBoard.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add Menu Board" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Menu Boards" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-type="menuBoard" data-grid-name="menuBoardView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("menuId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input('code', title) }}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "200px" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="row">
<div class="col-sm-1 form-group" style="padding: 0">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary btn-sm" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-bars fa-1x"></i></button>
</div>
<div class="form-group col-sm-11" style="padding: 0">
<div id="breadcrumbs" style="margin-top: 5px;"></div>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3">
<table id="menuBoards" class="table table-striped responsive nowrap" data-content-type="menuBoard" data-content-id-name="menuId" data-state-preference-name="menuBoardGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Permissions" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#menuBoards").DataTable({
"language": dataTablesLanguage,
"lengthMenu": [10, 25, 50, 100, 250, 500],
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
dataType: 'json',
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("menuBoard.search") }}",
"data": function (d) {
$.extend(d, $("#menuBoards").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "menuId", responsivePriority: 2},
{
"data": "name",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"data": "description",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"data": "code", responsivePriority: 3
},
{
"name": "modifiedDt",
"data": function (data) {
return moment.unix(data.modifiedDt).format(jsDateFormat);
}
},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#menuBoards_wrapper').find('.col-md-6').eq(1));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,120 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Resolutions"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("resolution.add") %}
<button class="btn btn-success XiboFormButton btns" title="{% trans "Add a new resolution for use on layouts" %}" href="{{ url_for("resolution.add.form") }}"><span class="fa fa-plus"></span> {% trans "Add Resolution" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Resolution" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="resolutionView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Enabled" %}{% endset %}
{% set option1 %}{% trans "Yes" %}{% endset %}
{% set option2 %}{% trans "No" %}{% endset %}
{% set values = [{id: 1, value: option1}, {id: 0, value: option2}] %}
{{ inline.dropdown("enabled", "single", title, 1, values, "id", "value") }}
</form>
</div>
</div>
<div class="XiboData card pt-3">
<table id="resolutions" class="table table-striped" data-state-preference-name="resolutionGrid">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Resolution" %}</th>
<th>{% trans "Width" %}</th>
<th>{% trans "Height" %}</th>
<th>{% trans "Enabled?" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
$(document).ready(function() {
var table = $("#resolutions").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("resolution.search") }}",
data: function (d) {
$.extend(d, $("#resolutions").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "resolutionId", responsivePriority: 2},
{"data": "resolution"},
{"data": "width"},
{"data": "height"},
{"data": "enabled"},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#resolutions_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
</script>
{% endblock %}

342
tmp/xibo-schedule-page.twig Normal file
View File

@@ -0,0 +1,342 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% import "forms.twig" as forms %}
{% block title %}{{ "Schedule"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("schedule.add") %}
<button class="btn btn-success XiboFormButton btns" title="{% trans "Add a new Scheduled event" %}"
href="{{ url_for("schedule.add.form") }}"><span class="fa fa-plus"></span> {% trans "Add Event" %}
</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Schedule" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="scheduleGridView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="schedule-filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab" aria-selected="true"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab" aria-selected="false"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="general-filter" role="tabpanel">
{% set title %}{% trans "Range" %}{% endset %}
{% set range %}{% trans "Custom" %}{% endset %}
{% set day %}{% trans "Day" %}{% endset %}
{% set week %}{% trans "Week" %}{% endset %}
{% set month %}{% trans "Month" %}{% endset %}
{% set year %}{% trans "Year" %}{% endset %}
{% set options = [
{ name: "custom", range: range },
{ name: "day", range: day },
{ name: "week", range: week },
{ name: "month", range: month },
{ name: "year", range: year },
] %}
{{ inline.dropdown("range", "single", title, "month", options, "name", "range", "", "date-range-input") }}
{% set title %}{% trans 'From Date' %}{% endset %}
{{ inline.dateTime("fromDt", title, "", "", "custom-date-range d-none", "", "") }}
{% set title %}{% trans 'To Date' %}{% endset %}
{{ inline.dateTime("toDt", title, "", "", "custom-date-range d-none", "", "") }}
{% set title %}{% trans "Date Controls" %}{% endset %}
<div class="form-group mr-1 mb-1 controls-date-range">
<div class="control-label mr-1" title=""
accesskey="">{{ title }}</div>
<div class="controls-date-inputs">
<div class="inputgroup date" id="dateInput">
<span class="btn btn-outline-primary date-open-button" role="button">
<i class="fa fa-calendar"></i>
</span>
<input type="text" class="form-control" id="dateInputLink" data-input style="display:none;"/>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary" data-calendar-nav="prev"><span class="fa fa-caret-left"></span> {% trans "Prev" %}</button>
<button type="button" class="btn btn-outline-secondary" data-calendar-nav="today">{% trans "Today" %}</button>
<button type="button" class="btn btn-secondary" data-calendar-nav="next">{% trans "Next" %} <span class="fa fa-caret-right"></span></button>
</div>
</div>
</div>
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans 'Event Type' %}{% endset %}
{{ inline.dropdown("eventTypeId", "single", title, "", [{eventTypeId: null, eventTypeName: "All"}]|merge(eventTypes), "eventTypeId", "eventTypeName") }}
{% set title %}{% trans "Layout / Campaign" %}{% endset %}
{% set helpText %}{% trans "Please select a Layout or Campaign for this Event to show" %}{% endset %}
<div class="form-group mr-1 mb-1">
<label class="control-label mr-1" for="campaignId" title=""
accesskey="">{{ title }}</label>
<select name="campaignId" id="campaignIdFilter" class="form-control"
data-search-url="{{ url_for("campaign.search") }}"
data-trans-campaigns="{% trans "Campaigns" %}"
data-trans-layouts="{% trans "Layouts" %}"
data-allow-clear="true"
data-width="100%"
title="{% trans "Layout / Campaign" %}"
data-placeholder="{% trans "Layout / Campaign" %}"
data-dropdownAutoWidth
>
</select>
</div>
{% set title %}{% trans "Displays" %}{% endset %}
<div class="form-group mr-1 mb-1 pagedSelect" style="min-width: 200px">
<label class="control-label mr-1" for="DisplayList" title=""
accesskey="">{{ title }}</label>
<select id="DisplayList" class="form-control" name="displaySpecificGroupIds[]"
data-width="100%"
data-placeholder="{% trans "Displays" %}"
data-search-url="{{ url_for("display.search") }}"
data-search-term="display"
data-id-property="displayGroupId"
data-text-property="display"
data-additional-property="displayGroupId"
data-allow-clear="true"
data-initial-key="displayGroupIds[]"
multiple>
</select>
</div>
{% set title %}{% trans "Display Groups" %}{% endset %}
<div class="form-group mr-2 mb-1 pagedSelect" style="min-width: 200px">
<label class="control-label mr-1" for="DisplayGroupList" title=""
accesskey="">{{ title }}</label>
<select id="DisplayGroupList" class="form-control" name="displayGroupIds[]"
data-width="100%"
data-placeholder="{% trans "Display Groups" %}"
data-search-url="{{ url_for("displayGroup.search") }}"
data-search-term="displayGroup"
data-id-property="displayGroupId"
data-text-property="displayGroup"
data-allow-clear="true"
data-initial-key="displayGroupIds[]"
multiple>
</select>
</div>
</div>
<div class="tab-pane" id="advanced-filter" role="tabpanel">
{% set label %}{% trans "Direct Schedule?" %}{% endset %}
{% set title %}{% trans "Show only events scheduled directly on selected Displays/Groups" %}{% endset %}
<div class="form-group ml-2 mr-3 mb-1">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="directSchedule" name="directSchedule">
<label class="form-check-label" title="{{ title }}" for="directSchedule" accesskey="">{{ label }}</label>
</div>
</div>
{% set title %}{% trans "Only show schedules which appear on all filtered displays/groups?" %}{% endset %}
{% set label %}{% trans "Shared Schedule?" %}{% endset %}
<div class="form-group ml-2 mr-3 mb-1">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="sharedSchedule" name="sharedSchedule">
<label class="form-check-label" title="{{ title }}" for="sharedSchedule" accesskey="">{{ label }}</label>
</div>
</div>
{% set title %}{% trans 'Geo Aware?' %}{% endset %}
{% set options = [
{ id: null, name: "Both"|trans },
{ id: 0, name: "No"|trans },
{ id: 1, name: "Yes"|trans }
] %}
{{ inline.dropdown("geoAware", "single", title, "both", options, "id", "name") }}
{% set title %}{% trans 'Recurring?' %}{% endset %}
{% set options = [
{ id: null, name: "Both" },
{ id: 0, name: "No"|trans },
{ id: 1, name: "Yes"|trans }
] %}
{{ inline.dropdown("recurring", "single", title, "both", options, "id", "name") }}
</div>
</div>
</form>
</div>
</div>
<div class="XiboSchedule card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<a class="schedule-nav grid-nav nav-link active" id="grid-tab" href="#grid-view"
data-schedule-view="grid"
role="tab"
data-toggle="tab"><span>{% trans "Grid" %}</span></a>
</li>
<li class="nav-item">
<a class="schedule-nav calendar-nav nav-link" id="calendar-tab" href="#calendar-view"
data-schedule-view="calendar"
data-calendar-view="month"
role="tab"
data-toggle="tab"><span>{% trans "Calendar" %}</span></a>
</li>
</ul>
</div>
<div class="card-body">
<div class="xibo-calendar-header-container col-xl-12 d-inline-flex justify-content-between">
<div class="xibo-calendar-header text-center d-inline-flex">
<h1 class="page-header"></h1>
</div>
<div class="calendar-loading">
<span id="calendar-progress-table" class="fa fa-spin fa-cog"></span>
<span id="calendar-progress" class="fa fa-spin fa-cog"></span>
</div>
</div>
<div class="tab-content">
<div class="tab-pane active" id="grid-view">
<div class="XiboData pt-3">
<table id="schedule-grid" class="table table-striped w-100"
data-state-preference-name="scheduleGrid">
<thead>
<tr>
<th>{% trans 'ID' %}</th>
<th></th>
<th>{% trans 'Event Type' %}</th>
<th>{% trans 'Name' %}</th>
<th>{% trans 'Start' %}</th>
<th>{% trans 'End' %}</th>
<th>{% trans 'Event' %}</th>
<th>{% trans 'Campaign ID' %}</th>
<th>{% trans 'Display Groups' %}</th>
<th>{% trans 'SoV' %}</th>
<th>{% trans 'Max Plays per Hour' %}</th>
<th>{% trans 'Geo Aware?' %}</th>
<th>{% trans 'Recurring?' %}</th>
<th>{% trans 'Recurrence Description' %}</th>
<th>{% trans 'Recurrence Type' %}</th>
<th>{% trans 'Recurrence Interval' %}</th>
<th>{% trans 'Recurrence Repeats On' %}</th>
<th>{% trans 'Recurrence End' %}</th>
<th>{% trans 'Priority?' %}</th>
<th>{% trans 'Criteria?' %}</th>
<th>{% trans 'Created On' %}</th>
<th>{% trans 'Updated On' %}</th>
<th>{% trans 'Modified By' %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<div class="tab-pane" id="calendar-view">
<div class="row">
<div id="CalendarContainer"
data-agenda-link="{{ url_for("schedule.events", {id: ':id'}) }}"
data-calendar-type="{{ settings.CALENDAR_TYPE }}" class="col-sm-12"
data-default-lat="{{ defaultLat }}"
data-default-long="{{ defaultLong }}">
<div class="calendar-view" id="Calendar"></div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<div class="cal-legend">
<ul>
<li class="event-always"><span
class="fa fa-retweet"></span> {% trans "Always showing" %}</li>
<li class="event-info"><span
class="fa fa-desktop"></span> {% trans "Single Display" %}</li>
<li class="event-success"><span
class="fa fa-desktop"></span> {% trans "Multi Display" %}</li>
<li class="event-important"><span
class="fa fa-bullseye"></span> {% trans "Priority" %}</li>
<li class="event-special"><span
class="fa fa-repeat"></span> {% trans "Recurring" %}</li>
<li class="event-inverse"><span
class="fa fa-lock"></span> {% trans "View Only" %}</li>
<li class="event-command"><span
class="fa fa-wrench"></span> {% trans "Command" %}</li>
<li class="event-interrupt"><span
class="fa fa-hand-paper"></span> {% trans "Interrupt" %}</li>
<li class="event-geo-location"><span
class="fa fa-map-marker"></span> {% trans "Geo Location" %}</li>
<li class="event-action"><span
class="fa fa-paper-plane "></span> {% trans "Interactive Action" %}
</li>
<li class="event-sync"><span
class="fa fa-refresh"></span> {% trans "Synchronised" %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables #}
<script type="text/javascript" nonce="{{ cspNonce }}">
{# JS variables #}
var scheduleRecurrenceDeleteUrl = "{{ url_for("schedule.recurrence.delete.form", {id:':id'}) }}";
var layoutPreviewUrl = "{{ theme.rootUri() }}preview/layout/preview/:id";
var scheduleSearchUrl = "{{ url_for("schedule.search") }}";
var userAgendaViewEnabled = "{{ currentUser.featureEnabled('schedule.agenda') }}";
{# Custom translations #}
var schedulePageTrans = {
always: "{% trans "Always" %}",
adjustTimesofTimer: "{% trans "Adjust the times of this timer. To add or remove a day, use the Display Profile." %}",
daysOfTheWeek: {
monday: "{% trans "Monday" %}",
tuesday: "{% trans "Tuesday" %}",
wednesday: "{% trans "Wednesday" %}",
thursday: "{% trans "Thursday" %}",
friday: "{% trans "Friday" %}",
saturday: "{% trans "Saturday" %}",
sunday: "{% trans "Sunday" %}",
},
};
</script>
{# Add page source code bundle ( JS ) #}
<script src="{{ theme.rootUri() }}dist/leaflet.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/pages/schedule-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{% endblock %}

View File

@@ -0,0 +1 @@
404: Not Found

View File

@@ -0,0 +1,277 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Templates"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("template.add") %}
<button class="btn btn-success XiboFormButton btns" title="{% trans "Add a new Template and jump to the layout editor." %}" href="{{ url_for("template.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add Template" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Templates" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="templateView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('template', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3">
<table id="templates" class="table table-striped" data-content-type="layout" data-content-id-name="layoutId" data-state-preference-name="templateGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Description" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Orientation" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Sharing" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
var table = $("#templates").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("template.search") }}",
"data": function(d) {
$.extend(d, $("#templates").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "layout", responsivePriority: 2},
{
"name": "publishedStatus",
responsivePriority: 2,
"data": function (data, type) {
if (data.publishedDate != null) {
var now = moment();
var published = moment(data.publishedDate);
var differenceMinutes = published.diff(now, 'minutes');
var momentDifference = moment(now).to(published);
if (differenceMinutes < -5) {
return data.publishedStatus.concat(" - ", translations.publishedStatusFailed);
} else {
return data.publishedStatus.concat(" - ", translations.publishedStatusFuture + " " + momentDifference);
}
} else {
return data.publishedStatus;
}
}
},
{ "data": "owner", responsivePriority: 3},
{
"name": "description",
"data": null,
responsivePriority: 3,
"render": {"_": "description", "display": "descriptionWithMarkup", "sort": "description"}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
"data": dataTableCreateTags,
responsivePriority: 3
},{% endif %}
{ data: 'orientation', responsivePriority: 10, visible: false},
{
responsivePriority: 3,
data: 'thumbnail',
render: function (data, type, row) {
if (type !== 'display') {
return row.layoutId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
} else {
var addUrl = '{{ url_for("layout.thumbnail.add", {id: ":id"}) }}'.replace(':id', row.layoutId);
return '<a class="img-replace generate-layout-thumbnail" href="' + addUrl + '">' +
'<img class="img-fluid" src="{{ theme.uri("img/thumbs/placeholder.png") }}" alt="{{ "Add Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#templates").closest(".XiboGrid").find(".FilterDiv form") } ,dataTableCreateTagEvents);
table.on('draw', function(e, settings) {
$('#' + e.target.id + ' .generate-layout-thumbnail').on('click', function(e) {
e.preventDefault();
var $anchor = $(this);
$.ajax({
url: $anchor.attr('href'),
method: 'POST',
success: function() {
$anchor.find('img').attr('src', $anchor.attr('href'));
$anchor.removeClass('generate-layout-thumbnail').attr('data-toggle', 'lightbox');
}
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#templates_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function templateFormOpen() {
if ($('#folder-tree-form-modal').length === 0) {
// compile tree folder modal and append it to Form
var folderTreeModal = templates['folder-tree'];
var treeConfig = {"container": "container-folder-form-tree", "modal": "folder-tree-form-modal"};
treeConfig.trans = translations.folderTree;
$("body").append(folderTreeModal(treeConfig));
$("#folder-tree-form-modal").on('hidden.bs.modal', function () {
// Fix for 2nd/overlay modal
$('.modal:visible').length && $(document.body).addClass('modal-open');
$(this).data('bs.modal', null);
});
}
// select current working folder if one is selected in the grid
if ($('#container-folder-tree').jstree("get_selected", true)[0] !== undefined) {
$('#templateAddForm' + ' #folderId').val($('#container-folder-tree').jstree("get_selected", true)[0].id);
}
initJsTreeAjax($('#folder-tree-form-modal').find('#container-folder-form-tree'), 'templateAddForm', true, 600);
$("#templateAddForm").submit(function(e) {
e.preventDefault();
var form = $(this);
var url = $(this).data().redirect;
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
cache: false,
dataType:"json",
success: function(xhr, textStatus, error) {
XiboSubmitResponse(xhr, form);
if (xhr.success) {
// Reload the designer
XiboRedirect(url.replace(":id", xhr.id));
}
}
});
});
}
function layoutPublishFormOpen() {
// Nothing to do here, but we use the same form on the layout designer and have a callback registered there
}
function layoutEditFormSaved() {
// Nothing to do here.
}
</script>
{% endblock %}

277
tmp/xibo-template-page.twig Normal file
View File

@@ -0,0 +1,277 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Templates"|trans }} | {% endblock %}
{% block actionMenu %}
<div class="widget-action-menu pull-right">
{% if currentUser.featureEnabled("template.add") %}
<button class="btn btn-success XiboFormButton btns" title="{% trans "Add a new Template and jump to the layout editor." %}" href="{{ url_for("template.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i> {% trans "Add Template" %}</button>
{% endif %}
<button class="btn btn-primary" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
{% endblock %}
{% block pageContent %}
<div class="widget">
<div class="widget-title">{% trans "Templates" %}</div>
<div class="widget-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="templateView">
<div class="XiboFilter card mb-3 bg-light">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('template', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="grid-with-folders-container">
<div class="grid-folder-tree-container p-3" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div class="folder-controller d-none">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-outline-secondary" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder fa-1x"></i></button>
<div id="breadcrumbs" class="mt-2 pl-2"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3">
<table id="templates" class="table table-striped" data-content-type="layout" data-content-id-name="layoutId" data-state-preference-name="templateGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Description" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Orientation" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Sharing" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
var table = $("#templates").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("template.search") }}",
"data": function(d) {
$.extend(d, $("#templates").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "layout", responsivePriority: 2},
{
"name": "publishedStatus",
responsivePriority: 2,
"data": function (data, type) {
if (data.publishedDate != null) {
var now = moment();
var published = moment(data.publishedDate);
var differenceMinutes = published.diff(now, 'minutes');
var momentDifference = moment(now).to(published);
if (differenceMinutes < -5) {
return data.publishedStatus.concat(" - ", translations.publishedStatusFailed);
} else {
return data.publishedStatus.concat(" - ", translations.publishedStatusFuture + " " + momentDifference);
}
} else {
return data.publishedStatus;
}
}
},
{ "data": "owner", responsivePriority: 3},
{
"name": "description",
"data": null,
responsivePriority: 3,
"render": {"_": "description", "display": "descriptionWithMarkup", "sort": "description"}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
"data": dataTableCreateTags,
responsivePriority: 3
},{% endif %}
{ data: 'orientation', responsivePriority: 10, visible: false},
{
responsivePriority: 3,
data: 'thumbnail',
render: function (data, type, row) {
if (type !== 'display') {
return row.layoutId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
} else {
var addUrl = '{{ url_for("layout.thumbnail.add", {id: ":id"}) }}'.replace(':id', row.layoutId);
return '<a class="img-replace generate-layout-thumbnail" href="' + addUrl + '">' +
'<img class="img-fluid" src="{{ theme.uri("img/thumbs/placeholder.png") }}" alt="{{ "Add Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#templates").closest(".XiboGrid").find(".FilterDiv form") } ,dataTableCreateTagEvents);
table.on('draw', function(e, settings) {
$('#' + e.target.id + ' .generate-layout-thumbnail').on('click', function(e) {
e.preventDefault();
var $anchor = $(this);
$.ajax({
url: $anchor.attr('href'),
method: 'POST',
success: function() {
$anchor.find('img').attr('src', $anchor.attr('href'));
$anchor.removeClass('generate-layout-thumbnail').attr('data-toggle', 'lightbox');
}
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#templates_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function templateFormOpen() {
if ($('#folder-tree-form-modal').length === 0) {
// compile tree folder modal and append it to Form
var folderTreeModal = templates['folder-tree'];
var treeConfig = {"container": "container-folder-form-tree", "modal": "folder-tree-form-modal"};
treeConfig.trans = translations.folderTree;
$("body").append(folderTreeModal(treeConfig));
$("#folder-tree-form-modal").on('hidden.bs.modal', function () {
// Fix for 2nd/overlay modal
$('.modal:visible').length && $(document.body).addClass('modal-open');
$(this).data('bs.modal', null);
});
}
// select current working folder if one is selected in the grid
if ($('#container-folder-tree').jstree("get_selected", true)[0] !== undefined) {
$('#templateAddForm' + ' #folderId').val($('#container-folder-tree').jstree("get_selected", true)[0].id);
}
initJsTreeAjax($('#folder-tree-form-modal').find('#container-folder-form-tree'), 'templateAddForm', true, 600);
$("#templateAddForm").submit(function(e) {
e.preventDefault();
var form = $(this);
var url = $(this).data().redirect;
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
cache: false,
dataType:"json",
success: function(xhr, textStatus, error) {
XiboSubmitResponse(xhr, form);
if (xhr.success) {
// Reload the designer
XiboRedirect(url.replace(":id", xhr.id));
}
}
});
});
}
function layoutPublishFormOpen() {
// Nothing to do here, but we use the same form on the layout designer and have a callback registered there
}
function layoutEditFormSaved() {
// Nothing to do here.
}
</script>
{% endblock %}