Added Roles, User Management, fixed bugs, added FlexTable component (should be renamed to GridTable), other table components and test code should be removed down the line, added admin function to fix broken data structures.
This commit is contained in:
56
imports/api/admin.js
Normal file
56
imports/api/admin.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import { _ } from 'underscore';
|
||||
import { Roles } from 'meteor/alanning:roles';
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.methods({
|
||||
'admin.fixRecords'(input) {
|
||||
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
|
||||
console.log("In Fix Records");
|
||||
console.log("Deleting invalid records...");
|
||||
|
||||
// Delete any records missing key fields.
|
||||
Meteor.Records.remove({serial: {$exists: false}});
|
||||
Meteor.Records.remove({deviceId: {$exists: false}});
|
||||
Meteor.Records.remove({endTime: {$exists: false}});
|
||||
|
||||
console.log("Consolidating records...");
|
||||
|
||||
let emails = _.uniq(Meteor.Records.find({}, {
|
||||
sort: {email: 1},
|
||||
fields: {email: true}
|
||||
}).fetch().map(function (x) {
|
||||
return x.email;
|
||||
}), true);
|
||||
|
||||
emails.forEach(email => {
|
||||
// Find all records for the user sorted from oldest to newest.
|
||||
let records = Meteor.Records.find({email}, {sort: {startTime: 1}}).fetch();
|
||||
let newRecords = [];
|
||||
let record = records[0];
|
||||
|
||||
for (let index = 1; index < records.length; index++) {
|
||||
let nextRecord = records[index];
|
||||
|
||||
if (record.deviceId === nextRecord.deviceId) {
|
||||
record.endTime = nextRecord.endTime;
|
||||
record.count += nextRecord.count;
|
||||
record.internalCount += nextRecord.internalCount;
|
||||
} else {
|
||||
if (!record.endTime) record.endTime = nextRecord.startTime;
|
||||
newRecords.push(record);
|
||||
record = nextRecord;
|
||||
}
|
||||
}
|
||||
newRecords.push(record);
|
||||
|
||||
Meteor.Records.remove({email});
|
||||
|
||||
for (let index = 0; index < newRecords.length; index++) {
|
||||
Meteor.Records.insert(newRecords[index]);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { check } from 'meteor/check';
|
||||
import { MongoClient } from 'mongodb';
|
||||
//import {Roles} from 'alanning/roles';
|
||||
|
||||
//export const Records = new Mongo.Collection('records');
|
||||
let client;
|
||||
@@ -9,44 +10,83 @@ let database;
|
||||
let dataCollection;
|
||||
|
||||
if (Meteor.isServer) {
|
||||
let uri = process.env.MONGO_URL2;
|
||||
// let uri = process.env.MONGO_URL2;
|
||||
// uri = "mongodb://localhost:27017";
|
||||
//
|
||||
// //client = new MongoClient(uri);
|
||||
// MongoClient.connect(uri, (err, c) => {
|
||||
// client = c;
|
||||
// database = client.db("avusd-data-collection");
|
||||
// dataCollection = database.collection("records");
|
||||
// dataCollection.find({deviceId: "1e3e99ef-adf4-4aa2-8784-205bc60f0ce3"}).toArray((e, a) => {
|
||||
// if(e) console.log(e);
|
||||
// else console.log("Found " + a.length + " records.");
|
||||
// })
|
||||
// });
|
||||
|
||||
client = new MongoClient(uri);
|
||||
database = client.db("avusd-data-collection");
|
||||
dataCollection = database.collection("records");
|
||||
|
||||
// This code only runs on the server
|
||||
Meteor.publish('chromebookData', function(deviceId) {
|
||||
check(deviceId, String);
|
||||
|
||||
return dataCollection.find({deviceId});
|
||||
});
|
||||
// let results = Meteor.Records.find({deviceId: "1e3e99ef-adf4-4aa2-8784-205bc60f0ce3"}).fetch();
|
||||
// console.log(results);
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
// 'tasks.setChecked'(taskId, setChecked) {
|
||||
// check(taskId, String);
|
||||
// check(setChecked, Boolean);
|
||||
//
|
||||
// const task = Tasks.findOne(taskId);
|
||||
// if (task.private && task.owner !== this.userId) {
|
||||
// // If the task is private, make sure only the owner can check it off
|
||||
// throw new Meteor.Error('not-authorized');
|
||||
// }
|
||||
//
|
||||
// Tasks.update(taskId, { $set: { checked: setChecked } });
|
||||
// },
|
||||
// 'tasks.setPrivate'(taskId, setToPrivate) {
|
||||
// check(taskId, String);
|
||||
// check(setToPrivate, Boolean);
|
||||
//
|
||||
// const task = Tasks.findOne(taskId);
|
||||
//
|
||||
// // Make sure only the task owner can make a task private
|
||||
// if (task.owner !== this.userId) {
|
||||
// throw new Meteor.Error('not-authorized');
|
||||
// }
|
||||
//
|
||||
// Tasks.update(taskId, { $set: { private: setToPrivate } });
|
||||
// },
|
||||
});
|
||||
if (Meteor.isServer) {
|
||||
Meteor.methods({
|
||||
/**
|
||||
* Collects Chromebook history given one of the possible parameters.
|
||||
* @param params An object with a single attribute. The attribute must be one of: deviceId, serial, email. It will find all Chromebook data that starts with the given attribute value.
|
||||
* @returns {any} Array of Chromebook data objects.
|
||||
*/
|
||||
'DataCollection.chromebookData'(params) {
|
||||
if(Roles.userIsInRole(Meteor.userId(), "laptop-management", {anyScope:true})) {
|
||||
let query = {};
|
||||
|
||||
if (params.deviceId) query.deviceId = params.regex ? {
|
||||
$regex: params.deviceId,
|
||||
$options: "i"
|
||||
} : params.deviceId;
|
||||
else if (params.serial) query.serial = params.regex ? {
|
||||
$regex: params.serial,
|
||||
$options: "i"
|
||||
} : params.serial;
|
||||
else if (params.email) query.email = params.regex ? {
|
||||
$regex: params.email,
|
||||
$options: "i"
|
||||
} : params.email;
|
||||
|
||||
// console.log("Collecting Chromebook Data: ");
|
||||
// console.log(query);
|
||||
let result = Meteor.Records.find(query).fetch();
|
||||
// console.log("Found: ");
|
||||
// console.log(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
else {return null;}
|
||||
}
|
||||
// 'tasks.setChecked'(taskId, setChecked) {
|
||||
// check(taskId, String);
|
||||
// check(setChecked, Boolean);
|
||||
//
|
||||
// const task = Tasks.findOne(taskId);
|
||||
// if (task.private && task.owner !== this.userId) {
|
||||
// // If the task is private, make sure only the owner can check it off
|
||||
// throw new Meteor.Error('not-authorized');
|
||||
// }
|
||||
//
|
||||
// Tasks.update(taskId, { $set: { checked: setChecked } });
|
||||
// },
|
||||
// 'tasks.setPrivate'(taskId, setToPrivate) {
|
||||
// check(taskId, String);
|
||||
// check(setToPrivate, Boolean);
|
||||
//
|
||||
// const task = Tasks.findOne(taskId);
|
||||
//
|
||||
// // Make sure only the task owner can make a task private
|
||||
// if (task.owner !== this.userId) {
|
||||
// throw new Meteor.Error('not-authorized');
|
||||
// }
|
||||
//
|
||||
// Tasks.update(taskId, { $set: { private: setToPrivate } });
|
||||
// },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import "./records.js";
|
||||
import "./users.js";
|
||||
import "./data-collection.js";
|
||||
import "./admin.js";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { Roles } from 'meteor/alanning:roles';
|
||||
import { check } from 'meteor/check';
|
||||
|
||||
if (Meteor.isServer) {
|
||||
Meteor.publish(null, function() {
|
||||
@@ -9,14 +10,59 @@ if (Meteor.isServer) {
|
||||
else {
|
||||
this.ready();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Meteor.methods({
|
||||
// 'users.setupInitialRoles'() {
|
||||
// Roles.createRole('admin');
|
||||
// Roles.createRole('laptop-management');
|
||||
// Roles.addRolesToParent('laptop-management', 'admin');
|
||||
// Roles.addUsersToRoles("zwbMiaSKHix4bWQ8d", 'admin', 'global');
|
||||
// }
|
||||
// });
|
||||
Meteor.publish(null, function() {
|
||||
return Meteor.roles.find({});
|
||||
});
|
||||
|
||||
Meteor.publish("allUsers", function() {
|
||||
// console.log(Meteor.isServer);
|
||||
// console.log("AllUsers");
|
||||
// console.log("Meteor.userId(): " + Meteor.userId());
|
||||
// // console.log(Roles.userIsInRole(Meteor.userId(), "laptop-management"));
|
||||
// console.log(Meteor.roleAssignment.find({ 'user._id': Meteor.userId() }).fetch());
|
||||
// console.log(Roles.userIsInRole(Meteor.user(), "admin", {anyScope:true}));
|
||||
|
||||
// Note: For some reason the {anyScope: true} is necessary on the server for the function to actually check roles.
|
||||
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
|
||||
//console.log(Meteor.users.find({}).fetch());
|
||||
return Meteor.users.find({});
|
||||
}
|
||||
else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
Meteor.publish("allRoleAssignments", function() {
|
||||
// Note: For some reason the {anyScope: true} is necessary on the server for the function to actually check roles.
|
||||
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
|
||||
return Meteor.roleAssignment.find({});
|
||||
}
|
||||
else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
'users.setUserRoles'(userId, roles) {
|
||||
if(Roles.userIsInRole(Meteor.userId(), "admin", {anyScope:true})) {
|
||||
check(userId, String);
|
||||
check(roles, Array);
|
||||
Roles.setUserRoles(userId, roles, {anyScope: true});
|
||||
}
|
||||
},
|
||||
// 'tasks.setPrivate'(taskId, setToPrivate) {
|
||||
// check(taskId, String);
|
||||
// check(setToPrivate, Boolean);
|
||||
//
|
||||
// const task = Tasks.findOne(taskId);
|
||||
//
|
||||
// // Make sure only the task owner can make a task private
|
||||
// if (task.owner !== this.userId) {
|
||||
// throw new Meteor.Error('not-authorized');
|
||||
// }
|
||||
//
|
||||
// Tasks.update(taskId, { $set: { private: setToPrivate } });
|
||||
// },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,70 @@
|
||||
import { Accounts } from 'meteor/accounts-base'
|
||||
import { Roles } from 'meteor/alanning:roles'
|
||||
import {Meteor} from "meteor/meteor";
|
||||
|
||||
if(Meteor.isCLient) {
|
||||
if(Meteor.isClient) {
|
||||
Accounts.ui.config({
|
||||
passwordSignupFields: 'USERNAME_ONLY'
|
||||
});
|
||||
}
|
||||
|
||||
Accounts.config({
|
||||
// Allow only certain email domains.
|
||||
restrictCreationByEmailDomain: function(address) {
|
||||
return new RegExp('.*@avpanthers.org$', 'i').test(address)
|
||||
let pattern = process.env.EMAIL_REGEX;
|
||||
|
||||
return new RegExp(pattern, 'i').test(address)
|
||||
}
|
||||
});
|
||||
|
||||
if(Meteor.isServer) {
|
||||
let adminEmail = process.env.ADMIN_EMAIL;
|
||||
let watchForAdmin = false;
|
||||
|
||||
//Setup the roles.
|
||||
Roles.createRole('admin', {unlessExists: true});
|
||||
Roles.createRole('laptop-management', {unlessExists: true});
|
||||
Roles.addRolesToParent('laptop-management', 'admin', {unlessExists: true});
|
||||
//Roles.addUsersToRoles("zwbMiaSKHix4bWQ8d", 'admin', 'global', {unlessExists: true});
|
||||
|
||||
// If we are passed an email address that should be admin by default, then ensure that user is admin, or mark it as needing to be admin if the user ever logs in.
|
||||
// Given that this app requires Google OAuth2, and we expect logins to be restricted to district email addresses, this should be very secure.
|
||||
if(adminEmail) {
|
||||
let user = Meteor.users.findOne({"services.google.email": adminEmail});
|
||||
|
||||
if(user) {
|
||||
let assignment = Meteor.roleAssignment.findOne({'user._id': user._id, "role._id": "admin"});
|
||||
|
||||
// console.log("Admin Role Assignment: " + JSON.stringify(assignment));
|
||||
if(!assignment) {
|
||||
Roles.addUsersToRoles(user._id, ['admin']);
|
||||
}
|
||||
}
|
||||
else {
|
||||
watchForAdmin = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for users logging in so we can setup the admin user automatically once they log in the first time.
|
||||
if(watchForAdmin) {
|
||||
// TODO: It would be nice to remove this handler after the admin user is found, but the docs are pretty ambiguous about how to do that. Not a big deal, just annoying.
|
||||
Accounts.onLogin(function (data) {
|
||||
// console.log("User logged in:");
|
||||
// console.log(data.user.services.google.email);
|
||||
|
||||
// data.user == Meteor.user()
|
||||
|
||||
//console.log(JSON.stringify(Meteor.user()));
|
||||
if (watchForAdmin) {
|
||||
try {
|
||||
if (data.user.services.google.email === adminEmail) {
|
||||
Roles.addUsersToRoles(data.user._id, ['admin']);
|
||||
watchForAdmin = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
8
imports/ui/Admin.svelte
Normal file
8
imports/ui/Admin.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script>
|
||||
import {Meteor} from "meteor/meteor";
|
||||
|
||||
const fixRecords = () => {Meteor.call("admin.fixRecords");}
|
||||
</script>
|
||||
<div class="container">
|
||||
<button type="button" on:click={fixRecords}>Fix Records</button>
|
||||
</div>
|
||||
26
imports/ui/Announcer.svelte
Normal file
26
imports/ui/Announcer.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<!-- Announcer.svelte - From the tinro docs: https://github.com/AlexxNB/tinro -->
|
||||
<!-- This will be included in the App.svelte so that URL changes are announced in a way that accessibility tools can read the page. -->
|
||||
<script>
|
||||
import { router } from 'tinro';
|
||||
$: current = $router.path === '/' ? 'Home' : $router.path.slice(1);
|
||||
</script>
|
||||
|
||||
<div aria-live="assertive" aria-atomic="true">
|
||||
{#key current}
|
||||
Navigated to {current}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,269 +1,242 @@
|
||||
|
||||
<script>
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import {Route, router} from 'tinro';
|
||||
import {onMount} from 'svelte';
|
||||
import {useTracker} from 'meteor/rdb:svelte-meteor-data';
|
||||
import {Roles} from 'meteor/alanning:roles';
|
||||
import Chromebooks from './Chromebooks.svelte';
|
||||
import Users from './Users.svelte';
|
||||
import TestTable from './TestTable.svelte';
|
||||
import ListUsers from './ListUsers.svelte';
|
||||
import Admin from './Admin.svelte';
|
||||
import Announcer from './Announcer.svelte';
|
||||
import {BlazeTemplate} from 'meteor/svelte:blaze-integration';
|
||||
import ServiceConfiguration from "meteor/service-configuration";
|
||||
|
||||
// When the URL changes, run the code... in this case to scroll to the top.
|
||||
router.subscribe(_ => window.scrollTo(0, 0));
|
||||
|
||||
// onMount(async () => {
|
||||
// // Meteor.subscribe('records');
|
||||
// });
|
||||
|
||||
// $: incompleteCount = useTracker(() => Tasks.find({checked: {$ne: true}}).count());
|
||||
|
||||
$: currentUser = useTracker(() => Meteor.user());
|
||||
$: canManageLaptops = false;
|
||||
$: isAdmin = false;
|
||||
|
||||
Tracker.autorun(() => {
|
||||
// For some reason currentUser is always null here, and is not reactive (user changes and this does not get re-called).
|
||||
let user = Meteor.user();
|
||||
canManageLaptops = user && Roles.userIsInRole(user._id, 'laptop-management', 'global');
|
||||
isAdmin = user && Roles.userIsInRole(user._id, 'admin', 'global');
|
||||
});
|
||||
|
||||
|
||||
// const taskStore = Tasks.find({}, {sort: {createdAt: -1}});
|
||||
// $: {
|
||||
// tasks = $taskStore;
|
||||
// if (hideCompleted) {
|
||||
// tasks = tasks.filter(task => !task.checked);
|
||||
// }
|
||||
// }
|
||||
|
||||
// function handleSubmit(event) {
|
||||
// Meteor.call("tasks.insert", newTask);
|
||||
// // Clear form
|
||||
// newTask = "";
|
||||
// }
|
||||
|
||||
function performLogin() {
|
||||
//Login style can be "popup" or "redirect". I am not sure we need to request and offline token.
|
||||
Meteor.loginWithGoogle({loginStyle: "popup", requestOfflineToken: true}, (err) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
} else {
|
||||
//console.log("Logged in");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function performLogout() {
|
||||
Meteor.logout();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Announcer/>
|
||||
|
||||
<div class="container">
|
||||
<header class="row">
|
||||
<div class="col-12 logoContainer">
|
||||
<img class="logo" src="/images/logo.svg"/>
|
||||
<div class="login">
|
||||
{#if !$currentUser}
|
||||
<button type="button" role="button" on:click={performLogin}>Login</button>
|
||||
{:else}
|
||||
<button type="button" role="button" on:click={performLogout}>Logout</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 center" style="margin-bottom: 0"><h1 style="margin-bottom: 0">District Central</h1></div>
|
||||
<div class="col-12 center">
|
||||
<div class="nav-separator"></div>
|
||||
</div>
|
||||
<nav class="col-12 center">
|
||||
<a href="/">Home</a>
|
||||
{#if canManageLaptops}
|
||||
<a href="/chromebooks">Chromebooks</a>
|
||||
{/if}
|
||||
{#if canManageLaptops}
|
||||
<a href="/users">Users</a>
|
||||
{/if}
|
||||
{#if isAdmin}
|
||||
<a href="/admin">Admin</a>
|
||||
{/if}
|
||||
<!-- <a href="/TestTable">Test</a>-->
|
||||
<!-- <a href="/ListUsers">List Users</a>-->
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<Route path="/">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
TODO: Some statistics and such.
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/ListUsers">
|
||||
<!-- <ListUsers/>-->
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
{#if isAdmin}
|
||||
<Admin/>
|
||||
{/if}
|
||||
</Route>
|
||||
<Route path="/TestTable/*">
|
||||
<!-- <TestTable/>-->
|
||||
</Route>
|
||||
<Route path="/chromebooks/*">
|
||||
{#if canManageLaptops}
|
||||
<Chromebooks/>
|
||||
{:else}
|
||||
<!-- User not authorized to use this UI. Don't render anything because it is likely the user is still loading and will have access in a moment. -->
|
||||
{/if}
|
||||
</Route>
|
||||
<Route path="/users/*">
|
||||
{#if isAdmin}
|
||||
<Users/>
|
||||
{:else}
|
||||
<!-- User not authorized to use this UI. Don't render anything because it is likely the user is still loading and will have access in a moment. -->
|
||||
{/if}
|
||||
</Route>
|
||||
|
||||
<style>
|
||||
nav {
|
||||
font-size: 2rem;
|
||||
@font-face {
|
||||
font-family: 'KaushanScript-Regular';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('/fonts/KaushanScript-Regular.ttf') format('truetype');
|
||||
}
|
||||
a {
|
||||
display: block;
|
||||
color: green;
|
||||
|
||||
.nav-separator {
|
||||
height: 0.2rem;
|
||||
width: 40%;
|
||||
margin-left: 30%;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
nav {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
color: #a6a6ea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: #6363ee;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
nav a + a {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: #000121;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
|
||||
}
|
||||
|
||||
/** Forbidden CSS */
|
||||
.maincontainer {
|
||||
position: relative;
|
||||
top: -50px;
|
||||
transform: scale(0.8);
|
||||
background: url("/public/images/forbidden/HauntedHouseBackground.png");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 700px 600px;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
margin: 0px auto;
|
||||
display: grid;
|
||||
}
|
||||
.foregroundimg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
top: -230px;
|
||||
z-index: 5;
|
||||
}
|
||||
.errorcode {
|
||||
position: relative;
|
||||
top: -200px;
|
||||
font-family: 'Creepster', cursive;
|
||||
header {
|
||||
background: #2c031c;
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-size: 6em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.errortext {
|
||||
/*background-image: linear-gradient(to bottom, #d0edf5, #e1e5f0 100%);*/
|
||||
padding: 20px 15px 15px 15px;
|
||||
position: relative;
|
||||
top: -260px;
|
||||
color: #FBD130;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
.bat {
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
transform-origin: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.bat:nth-child(1) {
|
||||
top: 380px;
|
||||
left: 120px;
|
||||
transform: scale(0.5);
|
||||
animation: 13s 1s flyBat1 infinite linear;
|
||||
}
|
||||
.bat:nth-child(2) {
|
||||
top: 280px;
|
||||
left: 80px;
|
||||
transform: scale(0.3);
|
||||
animation: 8s 4s flyBat2 infinite linear;
|
||||
}
|
||||
.bat:nth-child(3) {
|
||||
top: 200px;
|
||||
left: 150px;
|
||||
transform: scale(0.4);
|
||||
animation: 12s 2s flyBat3 infinite linear;
|
||||
}
|
||||
.body {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
top: 12px;
|
||||
}
|
||||
.wing {
|
||||
width: 150px;
|
||||
position: relative;
|
||||
transform-origin: right center;
|
||||
}
|
||||
.leftwing {
|
||||
left: 30px;
|
||||
animation: 0.8s flapLeft infinite ease-in-out;
|
||||
}
|
||||
.rightwing {
|
||||
left: -180px;
|
||||
transform: scaleX(-1);
|
||||
animation: 0.8s flapRight infinite ease-in-out;
|
||||
}
|
||||
@keyframes flapLeft {
|
||||
0% { transform: rotateZ(0); }
|
||||
50% { transform: rotateZ(10deg) rotateY(40deg); }
|
||||
100% { transform: rotateZ(0); }
|
||||
}
|
||||
@keyframes flapRight {
|
||||
0% { transform: scaleX(-1) rotateZ(0); }
|
||||
50% { transform: scaleX(-1) rotateZ(10deg) rotateY(40deg); }
|
||||
100% { transform: scaleX(-1) rotateZ(0); }
|
||||
}
|
||||
@keyframes flyBat1 {
|
||||
0% { opacity: 1; transform: scale(0.5)}
|
||||
25% { opacity: 1; transform: scale(0.5) translate(-400px, -330px) }
|
||||
50% { opacity: 1; transform: scale(0.5) translate(400px, -800px) }
|
||||
75% { opacity: 1; transform: scale(0.5) translate(600px, 100px) }
|
||||
100% { opacity: 1; transform: scale(0.5) translate(100px, 300px) }
|
||||
}
|
||||
@keyframes flyBat2 {
|
||||
0% { opacity: 1; transform: scale(0.3)}
|
||||
25% { opacity: 1; transform: scale(0.3) translate(200px, -330px) }
|
||||
50% { opacity: 1; transform: scale(0.3) translate(-300px, -800px) }
|
||||
75% { opacity: 1; transform: scale(0.3) translate(-400px, 100px) }
|
||||
100% { opacity: 1; transform: scale(0.3) translate(100px, 300px) }
|
||||
}
|
||||
@keyframes flyBat3 {
|
||||
0% { opacity: 1; transform: scale(0.4)}
|
||||
25% { opacity: 1; transform: scale(0.4) translate(-350px, -330px) }
|
||||
50% { opacity: 1; transform: scale(0.4) translate(400px, -800px) }
|
||||
75% { opacity: 1; transform: scale(0.4) translate(-600px, 100px) }
|
||||
100% { opacity: 1; transform: scale(0.4) translate(100px, 300px) }
|
||||
}
|
||||
/*@media only screen and (max-width: 850px) {
|
||||
.maincontainer {
|
||||
transform: scale(0.6);
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
background-size: 600px 400px;
|
||||
}
|
||||
|
||||
.errortext {
|
||||
font-size: 1em;
|
||||
}
|
||||
}*/
|
||||
h1 {
|
||||
color: white;
|
||||
font-family: "KaushanScript-Regular", sans-serif;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.login button {
|
||||
background: #7171ec;
|
||||
border-radius: 999px;
|
||||
box-shadow: #5E5DF0 0 10px 20px -10px;
|
||||
box-sizing: border-box;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
font-family: Inter, Helvetica, "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols, -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", sans-serif;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
opacity: 1;
|
||||
outline: 0 solid transparent;
|
||||
padding: 8px 18px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
width: fit-content;
|
||||
word-break: break-word;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.logo {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
margin: 0 auto;
|
||||
width: 8rem;
|
||||
}
|
||||
.logoContainer {
|
||||
height: 6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import {Route} from 'tinro';
|
||||
import {onMount} from 'svelte';
|
||||
import {useTracker} from 'meteor/rdb:svelte-meteor-data';
|
||||
import {Roles} from 'meteor/alanning:roles';
|
||||
import Chromebooks from './Chromebooks.svelte';
|
||||
import {BlazeTemplate} from 'meteor/svelte:blaze-integration';
|
||||
import {Records} from '../api/records.js'
|
||||
import ServiceConfiguration from "meteor/service-configuration";
|
||||
|
||||
//import './imports/ui/App.css';
|
||||
|
||||
//let currentUser;
|
||||
|
||||
onMount(async () => {
|
||||
// Meteor.subscribe('records');
|
||||
});
|
||||
|
||||
// $: incompleteCount = useTracker(() => Tasks.find({checked: {$ne: true}}).count());
|
||||
|
||||
$: currentUser = useTracker(() => Meteor.user());
|
||||
$: isAdmin = useTracker(() => Roles.userIsInRole(currentUser._id, 'laptop-management', 'global'));
|
||||
|
||||
// Tracker.autorun(() => {
|
||||
// let user = Meteor.user();
|
||||
// let isManagement = user ? Roles.userIsInRole(user._id, 'laptop-management', 'global') : 0;
|
||||
//
|
||||
// if(user && isManagement) {
|
||||
// new ProcessLaptops({
|
||||
// target: document.getElementById('appView')
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
// const taskStore = Tasks.find({}, {sort: {createdAt: -1}});
|
||||
// $: {
|
||||
// tasks = $taskStore;
|
||||
// if (hideCompleted) {
|
||||
// tasks = tasks.filter(task => !task.checked);
|
||||
// }
|
||||
// }
|
||||
|
||||
// function handleSubmit(event) {
|
||||
// Meteor.call("tasks.insert", newTask);
|
||||
// // Clear form
|
||||
// newTask = "";
|
||||
// }
|
||||
|
||||
function performLogin() {
|
||||
//Login style can be "popup" or "redirect". I am not sure we need to request and offline token.
|
||||
Meteor.loginWithGoogle({loginStyle: "popup", requestOfflineToken: true}, (err) => {
|
||||
if(err) {
|
||||
console.log(err);
|
||||
}
|
||||
else {
|
||||
//console.log("Logged in");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function performLogout() {
|
||||
Meteor.logout();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Route path="/">
|
||||
<div class="container">
|
||||
<header class="row">
|
||||
<nav class="col-12 center">
|
||||
<h1>AVUSD District Central</h1>
|
||||
<a href="/">Home</a>
|
||||
{#if isAdmin}
|
||||
<a href="/chromebooks">Chromebooks</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{#if !$currentUser}
|
||||
<button type="button" on:click={performLogin}>Login</button>
|
||||
{:else}
|
||||
<button type="button" on:click={performLogout}>Logout</button>
|
||||
{/if}
|
||||
</header>
|
||||
</div>
|
||||
</Route>
|
||||
{#if $currentUser}
|
||||
{#if isAdmin}
|
||||
<Route path="/chromebooks/*">
|
||||
<Chromebooks/>
|
||||
</Route>
|
||||
{:else}
|
||||
<Route fallback redirect="/"/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div className="container" style="background-color: #4d4242">
|
||||
<div class="maincontainer row">
|
||||
<div class="bat">
|
||||
<img class="wing leftwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
<img class="body"
|
||||
src="/images/forbidden/bat-body.png" alt="bat">
|
||||
<img class="wing rightwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
</div>
|
||||
<div class="bat">
|
||||
<img class="wing leftwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
<img class="body"
|
||||
src="/images/forbidden/bat-body.png" alt="bat">
|
||||
<img class="wing rightwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
</div>
|
||||
<div class="bat">
|
||||
<img class="wing leftwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
<img class="body"
|
||||
src="/images/forbidden/bat-body.png" alt="bat">
|
||||
<img class="wing rightwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
</div>
|
||||
<img class="foregroundimg" src="/images/forbidden/HauntedHouseForeground.png" alt="haunted house">
|
||||
|
||||
</div>
|
||||
<h1 class="errorcode">ERROR 403</h1>
|
||||
<div class="errortext">This area is forbidden. Turn back now!</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,41 +1,103 @@
|
||||
<style>
|
||||
.error {
|
||||
color: darkred;
|
||||
}
|
||||
.error {
|
||||
color: darkred;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
import {router} from 'tinro';
|
||||
import {Html5QrcodeScanner} from "html5-qrcode";
|
||||
import {Html5Qrcode} from "html5-qrcode";
|
||||
import {useTracker} from "meteor/rdb:svelte-meteor-data";
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import { Session } from 'meteor/session';
|
||||
|
||||
function onScanSuccess(decodedText, decodedResult) {
|
||||
console.log('Code matched ' + decodedResult);
|
||||
document.getElementById("log").prepend(decodedText);
|
||||
let c = 0;
|
||||
|
||||
$: deviceId = null;
|
||||
$: chromebookData = [];
|
||||
$: {
|
||||
if (deviceId) {
|
||||
Meteor.call("DataCollection.chromebookData", deviceId, (error, result) => {
|
||||
// console.log("Call returned");
|
||||
if (error) {
|
||||
console.error(error);
|
||||
} else {
|
||||
chromebookData = result;
|
||||
// console.log("result: " + result);
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
chromebookData = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onScanFailure(error) {
|
||||
function fakeScan() {
|
||||
setTimeout(() => {
|
||||
let decodedText = "1e3e99ef-adf4-4aa2-8784-205bc60f0ce3";
|
||||
|
||||
//deviceId = decodedText;
|
||||
//window.location.href="/chromebooks/byDevice/" + encodeURIComponent(decodedText);
|
||||
//router.location.goto("/chromebooks/byDevice/" + encodeURIComponent(decodedText));
|
||||
router.goto("/chromebooks?deviceId=" + encodeURIComponent(decodedText));
|
||||
}, 1000);
|
||||
}
|
||||
function clear() {
|
||||
deviceId = "";
|
||||
}
|
||||
|
||||
function setDeviceId(id) {
|
||||
deviceId = id;
|
||||
}
|
||||
|
||||
let html5QrCode;
|
||||
|
||||
function scanner() {
|
||||
let html5QrcodeScanner = new Html5QrcodeScanner("reader", {
|
||||
fps: 10,
|
||||
qrbox: {width: 250, height: 250}
|
||||
}, /* verbose */ false);
|
||||
html5QrCode = new Html5Qrcode("reader");
|
||||
const config = {fps: 10, qrbox: {width: 250, height: 250}};
|
||||
html5QrCode.start({facingMode: "environment"}, config, (decodedText, decodedResult) => {
|
||||
//console.log('Code matched ' + decodedText);
|
||||
//document.getElementById("log").prepend(JSON.stringify(decodedResult));
|
||||
//setDeviceId(decodedText);
|
||||
window.location.href="/chromebooks?deviceId=" + encodeURIComponent(decodedText);
|
||||
|
||||
html5QrcodeScanner.render(onScanSuccess, onScanFailure);
|
||||
// Stop Scanning
|
||||
html5QrCode.stop().then((ignore) => {
|
||||
// QR Code scanning is stopped.
|
||||
}).catch((err) => {
|
||||
// Stop failed, handle it.
|
||||
});
|
||||
}, (error) => {
|
||||
//TODO:
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<div className="container">
|
||||
<div class="row">
|
||||
Hello World!
|
||||
<div class="container">
|
||||
<div class="row col-12" style="margin-bottom: 1rem">
|
||||
<button type="button" on:click={fakeScan}>Fake Scan</button>
|
||||
<button type="button" on:click={clear}>Clear</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div use:scanner id="reader" width="300px"></div>
|
||||
<!-- <div class="row">-->
|
||||
<!-- <div use:scanner id="reader" width="300px"></div>-->
|
||||
<!-- </div>-->
|
||||
<div class="row col-12">
|
||||
<a href="#" on:click={scanner}>Scan</a>
|
||||
<!-- <button type="button" on:click={scanner}>Scan</button>-->
|
||||
<div id="reader" width="250px"></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<br/>
|
||||
<div class="row col-12">
|
||||
<div id="log" class="col-12">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row col-12">
|
||||
<ul>
|
||||
{#each chromebookData as data}
|
||||
<li>{data.email}<br/>{data.serial}<br/>{new Date(data.startTime).toLocaleDateString("en-US") + "-" + new Date(data.endTime).toLocaleDateString("en-US")}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,187 @@
|
||||
|
||||
<style>
|
||||
nav {
|
||||
font-size: 2rem;
|
||||
a.button {
|
||||
background: #7171ec;
|
||||
border-radius: 999px;
|
||||
box-shadow: #5E5DF0 0 10px 20px -10px;
|
||||
box-sizing: border-box;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
font-family: Inter,Helvetica,"Apple Color Emoji","Segoe UI Emoji",NotoColorEmoji,"Noto Color Emoji","Segoe UI Symbol","Android Emoji",EmojiSymbols,-apple-system,system-ui,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans",sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
opacity: 1;
|
||||
outline: 0 solid transparent;
|
||||
padding: 8px 18px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
width: fit-content;
|
||||
word-break: break-word;
|
||||
border: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
display: block;
|
||||
color: green;
|
||||
text-decoration: none;
|
||||
input {
|
||||
--background: #fff;
|
||||
--border-default: #D0D0DF;
|
||||
--border-active: #3D6DF9;
|
||||
/*--shadow-default: #{rgba(#202048, .12)};*/
|
||||
/*--shadow-active: #{rgba(#3D6DF9, .25)};*/
|
||||
--text-color: #818190;
|
||||
--placeholder-color: #C9C9D9;
|
||||
--placeholder-color-hover: #BABAC9;
|
||||
--close: #818190;
|
||||
--close-light: #BABAC9;
|
||||
--close-background: #F1F1FA;
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 1px;
|
||||
}
|
||||
|
||||
.options {
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {Route} from 'tinro';
|
||||
import {Route, router, meta} from 'tinro';
|
||||
import ChromebookScan from './ChromebookScan.svelte';
|
||||
import {Meteor} from "meteor/meteor";
|
||||
|
||||
$: serialInput = null;
|
||||
$: emailInput = null;
|
||||
function serialSearch() {
|
||||
router.goto("/chromebooks?serial=" + encodeURIComponent(serialInput) + "®ex=true");
|
||||
}
|
||||
function emailSearch() {
|
||||
router.goto("/chromebooks?email=" + encodeURIComponent(emailInput) + "®ex=true");
|
||||
}
|
||||
|
||||
// console.log("Loading Script");
|
||||
// //Attempt to listen for URL changes (query portion specifically).
|
||||
// (function(history){
|
||||
// const pushState = history.pushState;
|
||||
// history.pushState = function(state) {
|
||||
// if (typeof history.onpushstate == "function") {
|
||||
// history.onpushstate({state: state});
|
||||
// }
|
||||
// // Call your custom function here
|
||||
// console.log("Push state");
|
||||
// console.log(history);
|
||||
// console.log(arguments);
|
||||
// return pushState.apply(history, arguments);
|
||||
// }
|
||||
// })(window.history);
|
||||
|
||||
// const params = Object.fromEntries(new URLSearchParams(window.location.search));
|
||||
//
|
||||
// console.log("Params: ");
|
||||
// console.log(params);
|
||||
|
||||
$: deviceId = null;
|
||||
$: serial = null;
|
||||
$: email = null
|
||||
$: regex = false;
|
||||
$: router.subscribe(query => {
|
||||
deviceId = router.location.query.get("deviceId");
|
||||
serial = router.location.query.get("serial");
|
||||
email = router.location.query.get("email");
|
||||
regex = router.location.query.get("regex");
|
||||
|
||||
if(deviceId) deviceId = decodeURIComponent(deviceId);
|
||||
if(serial) serial = decodeURIComponent(serial);
|
||||
if(email) email = decodeURIComponent(email);
|
||||
if(regex) regex = true;
|
||||
|
||||
// console.log("Query:");
|
||||
// console.log(deviceId);
|
||||
// console.log(serial);
|
||||
// console.log(email);
|
||||
});
|
||||
$: chromebookData = null;
|
||||
$: {
|
||||
if(deviceId || serial || email) {
|
||||
let params = {};
|
||||
|
||||
if(deviceId) params.deviceId = deviceId;
|
||||
else if(serial) params.serial = serial;
|
||||
else if(email) params.email = email;
|
||||
|
||||
if(regex) params.regex = true;
|
||||
|
||||
Meteor.call("DataCollection.chromebookData", params, (error, result) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
} else {
|
||||
chromebookData = result;
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
chromebookData = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Route path="/">
|
||||
<div className="container">
|
||||
<div class="row">
|
||||
<nav class="col-12 center">
|
||||
<h1>Chromebook Management</h1>
|
||||
<a href="chromebooks/scan">Scan A Chromebook</a>
|
||||
<a href="chromebooks/byStudent">Chromebook History By Student</a>
|
||||
<a href="chromebooks/byChromebook">Chromebook History By Chromebook</a>
|
||||
</nav>
|
||||
<Route path="/" let:meta>
|
||||
{#if chromebookData}
|
||||
<div class="container">
|
||||
<div class="row col-12">
|
||||
<ul>
|
||||
{#each chromebookData as data}
|
||||
<li><a href="/chromebooks?email={encodeURIComponent(data.email)}">{data.email}</a><br/>
|
||||
<a href="/chromebooks?deviceId={encodeURIComponent(data.deviceId)}">{data.deviceId}</a><br/>
|
||||
<a href="/chromebooks?serial={encodeURIComponent(data.serial)}">{data.serial}</a><br/>
|
||||
{new Date(data.startTime).toLocaleDateString("en-US") + "-" + new Date(data.endTime).toLocaleDateString("en-US")}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="container">
|
||||
<div class="row col-12">
|
||||
<div class="options">
|
||||
<h1>Chromebook Management</h1>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
By Chromebook Device ID: <a href="/chromebooks/scan" className="button">Scan</a>
|
||||
</li>
|
||||
<li>
|
||||
By Chromebook Serial Number: <br/>
|
||||
<input type="text" bind:value="{serialInput}" placeholder="Serial Number"/><br/>
|
||||
<button type="button" role="button" on:click={serialSearch}>Search</button>
|
||||
</li>
|
||||
<li>
|
||||
By Email Address: <br/>
|
||||
<input type="text" bind:value="{emailInput}" placeholder="Email"/>@avpanthers.org<br/>
|
||||
<button type="button" role="button" on:click={emailSearch}>Search</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Route>
|
||||
|
||||
<Route path="/byDevice/:deviceId">
|
||||
<div class="container">
|
||||
<div class="row col-12">
|
||||
<ul>
|
||||
{#each chromebookData as data}
|
||||
<li>{data.email}<br/>{data.serial}
|
||||
<br/>{new Date(data.startTime).toLocaleDateString("en-US") + "-" + new Date(data.endTime).toLocaleDateString("en-US")}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<Route path="/scan">
|
||||
<ChromebookScan/>
|
||||
</Route>
|
||||
|
||||
181
imports/ui/FlexTable.svelte
Normal file
181
imports/ui/FlexTable.svelte
Normal file
@@ -0,0 +1,181 @@
|
||||
<script>
|
||||
export let rows;
|
||||
export let columns;
|
||||
export let rowKey;
|
||||
export let edited;
|
||||
|
||||
// Setup a width for each column.
|
||||
columns.forEach(column => {
|
||||
let min = column.minWidth ? Math.max(10, column.minWidth) : 10;
|
||||
let weight = column.weight ? Math.max(1, column.weight) : 1;
|
||||
column.width = 'minmax(' + min + 'px, ' + weight + 'fr)';
|
||||
});
|
||||
let gridTemplateColumns = columns.map(({width}) => width).join(' ');
|
||||
|
||||
let headerBeingResized = null;
|
||||
let horizontalScrollOffset = 0;
|
||||
const initResize = ({target}) => {
|
||||
headerBeingResized = target.parentNode;
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', completeResize);
|
||||
headerBeingResized.classList.add('header--being-resized');
|
||||
};
|
||||
const completeResize = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', completeResize);
|
||||
headerBeingResized.classList.remove('header--being-resized');
|
||||
headerBeingResized = null;
|
||||
};
|
||||
const onMouseMove = e => {
|
||||
try {
|
||||
// Calculate the desired width.
|
||||
horizontalScrollOffset = document.documentElement.scrollLeft;
|
||||
let parentX = Math.round(headerBeingResized.getBoundingClientRect().x);
|
||||
const width = horizontalScrollOffset + (e.clientX - parentX);
|
||||
// Update the column object with the new size value.
|
||||
const column = columns.find(({element}) => element === headerBeingResized);
|
||||
column.width = Math.max(column.minWidth, width) + "px";
|
||||
// Ensure all the column widths are converted to fixed sizes.
|
||||
columns.forEach((column, index) => {
|
||||
if((index < columns.length - 1) && (column.width.startsWith('minmax'))) {
|
||||
column.width = parseInt(column.element.clientWidth, 10) + 'px';
|
||||
}
|
||||
});
|
||||
// Render the new column sizes.
|
||||
gridTemplateColumns = columns.map(({width}) => width).join(' ');
|
||||
} catch(e) {console.log(e);}
|
||||
}
|
||||
|
||||
let selectedRowElement = null;
|
||||
const selectRow = (e, row) => {
|
||||
let element = e.target;
|
||||
|
||||
while(element && element.nodeName !== "TR") element = element.parentNode;
|
||||
|
||||
if(selectedRowElement) {
|
||||
selectedRowElement.classList.remove('selected');
|
||||
}
|
||||
|
||||
selectedRowElement = element;
|
||||
element.classList.add('selected');
|
||||
}
|
||||
|
||||
let editorContainer;
|
||||
const editRow = (e, row) => {
|
||||
let element = e.target;
|
||||
while(element && element.nodeName !== "TR") element = element.parentNode;
|
||||
let editor = element.querySelector('.editor');
|
||||
|
||||
// Save the edited row so the editor has access to it.
|
||||
$edited = row;
|
||||
|
||||
if(editor) {
|
||||
editor.appendChild(editorContainer);
|
||||
}
|
||||
editorContainer.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={editorContainer} class="hidden"><slot>Slot</slot></div>
|
||||
<table style="--grid-template-columns: {gridTemplateColumns}">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th bind:this={column.element}>{column.title} <span class="resize-handle" on:mousedown={initResize}></span></th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $rows as row (rowKey(row))}
|
||||
<!-- data-key="{rowKey(row)}"-->
|
||||
<tr class:hidden={row === $edited} on:mousedown={(e) => selectRow(e, row)} on:dblclick={(e) => editRow(e, row)}>
|
||||
{#each columns as column}
|
||||
<td>{column.value(row)}</td>
|
||||
{/each}
|
||||
<td class="editor"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<button on:click={() => {$edited = null}} type="button">Stop Editing</button>
|
||||
|
||||
<style>
|
||||
table {
|
||||
width: auto;
|
||||
-webkit-box-flex: 1;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
border-collapse: collapse;
|
||||
grid-template-columns: var(--grid-template-columns);
|
||||
}
|
||||
thead, tbody, tr {
|
||||
display: contents;
|
||||
}
|
||||
th, td {
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
th {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #5cb85c;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
th:last-child {
|
||||
border: 0;
|
||||
}
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: black;
|
||||
opacity: 0;
|
||||
width: 3px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
th:last-child .resize-handle {
|
||||
display: none;
|
||||
}
|
||||
.resize-handle:hover, .header--being-resized .resize-handle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
th:hover .resize-handle {
|
||||
opacity: 0.3;
|
||||
}
|
||||
td {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
color: #808080;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background: #f8f6ff;
|
||||
}
|
||||
:global(.selected), :global(.selected) > td {
|
||||
background-color: yellow;
|
||||
}
|
||||
.editor {
|
||||
grid-column: 1 / 4;
|
||||
display: none;
|
||||
}
|
||||
/*:global(td.hidden) {*/
|
||||
/* display: none;*/
|
||||
/*}*/
|
||||
:global(tr.hidden) > td:not(.editor) {
|
||||
display: none !important;
|
||||
}
|
||||
:global(tr.hidden) > td.editor {
|
||||
display: block !important;
|
||||
}
|
||||
:global(div.hidden) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
50
imports/ui/ListUsers.svelte
Normal file
50
imports/ui/ListUsers.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import TestUsers from "./TestUsers.svelte";
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
$: users = Meteor.users.find({});
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "_id",
|
||||
title: "ID",
|
||||
value: v => v._id,
|
||||
minWidth: 20,
|
||||
weight: 1,
|
||||
cls: "id",
|
||||
}, {
|
||||
key: "name",
|
||||
title: "Name",
|
||||
value: v => v.profile.name,
|
||||
minWidth: 100,
|
||||
weight: 1,
|
||||
cls: "name",
|
||||
}, {
|
||||
key: "roles",
|
||||
title: "Roles",
|
||||
value: user => {
|
||||
return Roles.getRolesForUser(user, {anyScope: true});
|
||||
},
|
||||
minWidth: 150,
|
||||
weight: 2,
|
||||
cls: "roles",
|
||||
}
|
||||
];
|
||||
|
||||
const getRowKey = user => {return user._id;}
|
||||
let edited = writable(null);
|
||||
</script>
|
||||
|
||||
{#await Meteor.subscribe('allUsers')}
|
||||
Loading...
|
||||
{:then allUsers}
|
||||
<TestUsers bind:rows="{users}" columns="{columns}" rowKey="{getRowKey}" bind:edited={edited}>
|
||||
{#if $edited}
|
||||
<input type="text" bind:value={$edited.profile.name}/>
|
||||
{/if}
|
||||
</TestUsers>
|
||||
{:catch error}
|
||||
{error.message}
|
||||
{/await}
|
||||
158
imports/ui/NotAuthorized.svelte
Normal file
158
imports/ui/NotAuthorized.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
|
||||
<style>
|
||||
.maincontainer {
|
||||
position: relative;
|
||||
top: -50px;
|
||||
transform: scale(0.8);
|
||||
background: url("/public/images/forbidden/HauntedHouseBackground.png");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: 700px 600px;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
margin: 0px auto;
|
||||
display: grid;
|
||||
}
|
||||
.foregroundimg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
top: -230px;
|
||||
z-index: 5;
|
||||
}
|
||||
.errorcode {
|
||||
position: relative;
|
||||
top: -200px;
|
||||
font-family: 'Creepster', cursive;
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-size: 6em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.errortext {
|
||||
position: relative;
|
||||
top: -260px;
|
||||
color: #FBD130;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
.bat {
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
transform-origin: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.bat:nth-child(1) {
|
||||
top: 380px;
|
||||
left: 120px;
|
||||
transform: scale(0.5);
|
||||
animation: 13s 1s flyBat1 infinite linear;
|
||||
}
|
||||
.bat:nth-child(2) {
|
||||
top: 280px;
|
||||
left: 80px;
|
||||
transform: scale(0.3);
|
||||
animation: 8s 4s flyBat2 infinite linear;
|
||||
}
|
||||
.bat:nth-child(3) {
|
||||
top: 200px;
|
||||
left: 150px;
|
||||
transform: scale(0.4);
|
||||
animation: 12s 2s flyBat3 infinite linear;
|
||||
}
|
||||
.body {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
top: 12px;
|
||||
}
|
||||
.wing {
|
||||
width: 150px;
|
||||
position: relative;
|
||||
transform-origin: right center;
|
||||
}
|
||||
.leftwing {
|
||||
left: 30px;
|
||||
animation: 0.8s flapLeft infinite ease-in-out;
|
||||
}
|
||||
.rightwing {
|
||||
left: -180px;
|
||||
transform: scaleX(-1);
|
||||
animation: 0.8s flapRight infinite ease-in-out;
|
||||
}
|
||||
@keyframes flapLeft {
|
||||
0% { transform: rotateZ(0); }
|
||||
50% { transform: rotateZ(10deg) rotateY(40deg); }
|
||||
100% { transform: rotateZ(0); }
|
||||
}
|
||||
@keyframes flapRight {
|
||||
0% { transform: scaleX(-1) rotateZ(0); }
|
||||
50% { transform: scaleX(-1) rotateZ(10deg) rotateY(40deg); }
|
||||
100% { transform: scaleX(-1) rotateZ(0); }
|
||||
}
|
||||
@keyframes flyBat1 {
|
||||
0% { opacity: 1; transform: scale(0.5)}
|
||||
25% { opacity: 1; transform: scale(0.5) translate(-400px, -330px) }
|
||||
50% { opacity: 1; transform: scale(0.5) translate(400px, -800px) }
|
||||
75% { opacity: 1; transform: scale(0.5) translate(600px, 100px) }
|
||||
100% { opacity: 1; transform: scale(0.5) translate(100px, 300px) }
|
||||
}
|
||||
@keyframes flyBat2 {
|
||||
0% { opacity: 1; transform: scale(0.3)}
|
||||
25% { opacity: 1; transform: scale(0.3) translate(200px, -330px) }
|
||||
50% { opacity: 1; transform: scale(0.3) translate(-300px, -800px) }
|
||||
75% { opacity: 1; transform: scale(0.3) translate(-400px, 100px) }
|
||||
100% { opacity: 1; transform: scale(0.3) translate(100px, 300px) }
|
||||
}
|
||||
@keyframes flyBat3 {
|
||||
0% { opacity: 1; transform: scale(0.4)}
|
||||
25% { opacity: 1; transform: scale(0.4) translate(-350px, -330px) }
|
||||
50% { opacity: 1; transform: scale(0.4) translate(400px, -800px) }
|
||||
75% { opacity: 1; transform: scale(0.4) translate(-600px, 100px) }
|
||||
100% { opacity: 1; transform: scale(0.4) translate(100px, 300px) }
|
||||
}
|
||||
/*@media only screen and (max-width: 850px) {
|
||||
.maincontainer {
|
||||
transform: scale(0.6);
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
background-size: 600px 400px;
|
||||
}
|
||||
|
||||
.errortext {
|
||||
font-size: 1em;
|
||||
}
|
||||
}*/
|
||||
</style>
|
||||
|
||||
<div className="container" style="background-color: #4d4242">
|
||||
<div class="maincontainer row">
|
||||
<div class="bat">
|
||||
<img class="wing leftwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
<img class="body"
|
||||
src="/images/forbidden/bat-body.png" alt="bat">
|
||||
<img class="wing rightwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
</div>
|
||||
<div class="bat">
|
||||
<img class="wing leftwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
<img class="body"
|
||||
src="/images/forbidden/bat-body.png" alt="bat">
|
||||
<img class="wing rightwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
</div>
|
||||
<div class="bat">
|
||||
<img class="wing leftwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
<img class="body"
|
||||
src="/images/forbidden/bat-body.png" alt="bat">
|
||||
<img class="wing rightwing"
|
||||
src="/images/forbidden/bat-wing.png">
|
||||
</div>
|
||||
<img class="foregroundimg" src="/images/forbidden/HauntedHouseForeground.png" alt="haunted house">
|
||||
|
||||
</div>
|
||||
<h1 class="errorcode">ERROR 403</h1>
|
||||
<div class="errortext">This area is forbidden. Turn back now!</div>
|
||||
</div>
|
||||
105
imports/ui/Table.svelte
Normal file
105
imports/ui/Table.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
/** @type {Array<Object>} */
|
||||
export let columns;
|
||||
/** @type {Array<Object>} */
|
||||
export let rows;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
// Create a UUID for creating unique instance styles later.
|
||||
const instanceId = Date.now().toString(36) + Math.random().toString(16).slice(2);
|
||||
|
||||
let columnByKey;
|
||||
$: {
|
||||
columnByKey = {};
|
||||
columns.forEach(column => {
|
||||
columnByKey[column.key] = column;
|
||||
});
|
||||
}
|
||||
|
||||
let columnClasses = [];
|
||||
// Create a custom class (style) for each column so we can control the column sizes.
|
||||
$: {
|
||||
// Remove old classes.
|
||||
if(columnClasses && columnClasses.length) {
|
||||
columnClasses.forEach(cls => {
|
||||
try {
|
||||
document.getElementsByTagName('head')[0].removeChild(cls);
|
||||
}
|
||||
catch(e) {console.log(e);}
|
||||
});
|
||||
}
|
||||
|
||||
// Create a unique class for each column so we can manage column sizes.
|
||||
columns.forEach((column, index) => {
|
||||
try {
|
||||
let cls = document.createElement('style');
|
||||
cls.type = 'text/css';
|
||||
column.customClassName = 'svelte-' + instanceId + '-column-' + index;
|
||||
cls.innerHTML = column.customClassName + "{min-width: " + column.width + "; max-width: " + column.width + "; width: " + column.width + ";}";
|
||||
columnClasses[index] = cls;
|
||||
document.getElementsByTagName('head')[0].appendChild(cls);
|
||||
} catch(e) {console.log(e);}
|
||||
});
|
||||
}
|
||||
|
||||
// Used to create a list of classes for tags.
|
||||
const asStringArray = v => [].concat(v).filter(v => typeof v === "string" && v !== "").join(" ");
|
||||
|
||||
let section;
|
||||
let table;
|
||||
// onMount(async () => {
|
||||
// //let hiddenHeaders = table.querySelectorAll("tbody tr:first-child td");
|
||||
// let hiddenHeaders = table.querySelectorAll("th");
|
||||
// section.querySelectorAll("th").forEach((th, index) => {
|
||||
// hiddenHeaders[index].style.width = th.getBoundingClientRect().width + 'px';
|
||||
// });
|
||||
// });
|
||||
</script>
|
||||
|
||||
<section bind:this={section}>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th class="{column.cls} cell" style="--width-{column.key}: {column.width}; min-width: var(--width-{column.key}); max-width: var(--width-{column.key}); width: var(--width-{column.key});">{column.title}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
</section>
|
||||
<table bind:this={table}>
|
||||
<thead class="table-head">
|
||||
<tr class="table-head">
|
||||
{#each columns as column}
|
||||
<th class="{column.cls} cell table-head" style="--width-{column.key}: {column.width}; min-width: var(--width-{column.key}); max-width: var(--width-{column.key}); width: var(--width-{column.key});" data-key="{column.key}">{column.title}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row}
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<td class="{column.cls} cell" style="--width-{column.key}: {column.width}; min-width: var(--width-{column.key}); max-width: var(--width-{column.key}); width: var(--width-{column.key});">{column.value(row)}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
section th:not(:last-child) {
|
||||
border-right: 4px solid gray;
|
||||
}
|
||||
.table-head {
|
||||
visibility: hidden;
|
||||
line-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
335
imports/ui/Table2.svelte
Normal file
335
imports/ui/Table2.svelte
Normal file
@@ -0,0 +1,335 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
/** @type {Array<Object>} */
|
||||
export let columns;
|
||||
|
||||
/** @type {Array<Object>} */
|
||||
export let rows;
|
||||
|
||||
/** @type {Array<Object>} */
|
||||
export let c_rows;
|
||||
|
||||
/** @type {Array<number>} */
|
||||
export let sortOrders = [1, -1];
|
||||
|
||||
// READ AND WRITE
|
||||
|
||||
/** @type {string} */
|
||||
export let sortBy = "";
|
||||
|
||||
/** @type {number} */
|
||||
export let sortOrder = sortOrders?.[0] || 1;
|
||||
|
||||
/** @type {Object} */
|
||||
export let filterSelections = {};
|
||||
|
||||
// expand
|
||||
/** @type {Array.<string|number>} */
|
||||
export let expanded = [];
|
||||
|
||||
// READ ONLY
|
||||
|
||||
/** @type {string} */
|
||||
export let expandRowKey = null;
|
||||
|
||||
/** @type {string} */
|
||||
export let expandSingle = false;
|
||||
|
||||
/** @type {string} */
|
||||
export let iconAsc = "▲";
|
||||
|
||||
/** @type {string} */
|
||||
export let iconDesc = "▼";
|
||||
|
||||
/** @type {string} */
|
||||
export let iconSortable = "";
|
||||
|
||||
/** @type {string} */
|
||||
export let iconExpand = "▼";
|
||||
|
||||
/** @type {string} */
|
||||
export let iconExpanded = "▲";
|
||||
|
||||
/** @type {boolean} */
|
||||
export let showExpandIcon = false;
|
||||
|
||||
/** @type {string} */
|
||||
export let classNameTable = "";
|
||||
|
||||
/** @type {string} */
|
||||
export let classNameThead = "";
|
||||
|
||||
/** @type {string} */
|
||||
export let classNameTbody = "";
|
||||
|
||||
/** @type {string} */
|
||||
export let classNameSelect = "";
|
||||
|
||||
/** @type {string} */
|
||||
export let classNameInput = "";
|
||||
|
||||
/** @type {string} */
|
||||
export let classNameRow = "";
|
||||
|
||||
/** @type {string} */
|
||||
export let classNameCell = "";
|
||||
|
||||
/** @type {string} class added to the expanded row*/
|
||||
export let classNameRowExpanded = "";
|
||||
|
||||
/** @type {string} class added to the expanded row*/
|
||||
export let classNameExpandedContent = "";
|
||||
|
||||
/** @type {string} class added to the cell that allows expanding/closing */
|
||||
export let classNameCellExpand = "";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let sortFunction = () => "";
|
||||
|
||||
// Validation
|
||||
if (!Array.isArray(expanded)) throw "'expanded' needs to be an array";
|
||||
|
||||
let showFilterHeader = columns.some(c => {
|
||||
// check if there are any filter or search headers
|
||||
return c.filterOptions !== undefined || c.searchValue !== undefined;
|
||||
});
|
||||
let filterValues = {};
|
||||
let columnByKey;
|
||||
$: {
|
||||
columnByKey = {};
|
||||
columns.forEach(col => {
|
||||
columnByKey[col.key] = col;
|
||||
});
|
||||
}
|
||||
|
||||
$: colspan = (showExpandIcon ? 1 : 0) + columns.length;
|
||||
|
||||
console.log(rows);
|
||||
|
||||
$: c_rows = rows
|
||||
.filter(r => {
|
||||
// get search and filter results/matches
|
||||
return Object.keys(filterSelections).every(f => {
|
||||
// check search (text input) matches
|
||||
let resSearch =
|
||||
filterSelections[f] === "" ||
|
||||
(columnByKey[f].searchValue &&
|
||||
(columnByKey[f].searchValue(r) + "")
|
||||
.toLocaleLowerCase()
|
||||
.indexOf((filterSelections[f] + "").toLocaleLowerCase()) >= 0);
|
||||
|
||||
// check filter (dropdown) matches
|
||||
let resFilter =
|
||||
resSearch ||
|
||||
filterSelections[f] === undefined ||
|
||||
// default to value() if filterValue() not provided in col
|
||||
filterSelections[f] ===
|
||||
(typeof columnByKey[f].filterValue === "function"
|
||||
? columnByKey[f].filterValue(r)
|
||||
: columnByKey[f].value(r));
|
||||
return resFilter;
|
||||
});
|
||||
})
|
||||
.map(r =>
|
||||
Object.assign({}, r, {
|
||||
// internal row property for sort order
|
||||
$sortOn: sortFunction(r),
|
||||
// internal row property for expanded rows
|
||||
$expanded:
|
||||
expandRowKey !== null && expanded.indexOf(r[expandRowKey]) >= 0,
|
||||
})
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (!sortBy) return 0;
|
||||
else if (a.$sortOn > b.$sortOn) return sortOrder;
|
||||
else if (a.$sortOn < b.$sortOn) return -sortOrder;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const asStringArray = v =>
|
||||
[]
|
||||
.concat(v)
|
||||
.filter(v => typeof v === "string" && v !== "")
|
||||
.join(" ");
|
||||
|
||||
const calculateFilterValues = () => {
|
||||
filterValues = {};
|
||||
columns.forEach(c => {
|
||||
if (typeof c.filterOptions === "function") {
|
||||
filterValues[c.key] = c.filterOptions(rows);
|
||||
} else if (Array.isArray(c.filterOptions)) {
|
||||
// if array of strings is provided, use it for name and value
|
||||
filterValues[c.key] = c.filterOptions.map(val => ({
|
||||
name: val,
|
||||
value: val,
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$: {
|
||||
let col = columnByKey[sortBy];
|
||||
if (
|
||||
col !== undefined &&
|
||||
col.sortable === true &&
|
||||
typeof col.value === "function"
|
||||
) {
|
||||
sortFunction = r => col.value(r);
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
// if filters are enabled, watch rows and columns
|
||||
if (showFilterHeader && columns && rows) {
|
||||
calculateFilterValues();
|
||||
}
|
||||
}
|
||||
|
||||
const updateSortOrder = colKey => {
|
||||
return colKey === sortBy
|
||||
? sortOrders[
|
||||
(sortOrders.findIndex(o => o === sortOrder) + 1) % sortOrders.length
|
||||
]
|
||||
: sortOrders[0];
|
||||
};
|
||||
|
||||
const handleClickCol = (event, col) => {
|
||||
if (col.sortable) {
|
||||
sortOrder = updateSortOrder(col.key);
|
||||
sortBy = sortOrder ? col.key : undefined;
|
||||
}
|
||||
dispatch("clickCol", { event, col, key: col.key });
|
||||
};
|
||||
|
||||
const handleClickRow = (event, row) => {
|
||||
dispatch("clickRow", { event, row });
|
||||
};
|
||||
|
||||
const handleClickExpand = (event, row) => {
|
||||
row.$expanded = !row.$expanded;
|
||||
const keyVal = row[expandRowKey];
|
||||
if (expandSingle && row.$expanded) {
|
||||
expanded = [keyVal];
|
||||
} else if (expandSingle) {
|
||||
expanded = [];
|
||||
} else if (!row.$expanded) {
|
||||
expanded = expanded.filter(r => r != keyVal);
|
||||
} else {
|
||||
expanded = [...expanded, keyVal];
|
||||
}
|
||||
dispatch("clickExpand", { event, row });
|
||||
};
|
||||
|
||||
const handleClickCell = (event, row, key) => {
|
||||
dispatch("clickCell", { event, row, key });
|
||||
};
|
||||
</script>
|
||||
|
||||
<table class={asStringArray(classNameTable)}>
|
||||
<thead class={asStringArray(classNameThead)}>
|
||||
{#if showFilterHeader}
|
||||
<tr>
|
||||
{#each columns as col}
|
||||
<th class={asStringArray([col.headerFilterClass])}>
|
||||
{#if col.searchValue !== undefined}
|
||||
<input
|
||||
bind:value={filterSelections[col.key]}
|
||||
class={asStringArray(classNameInput)}
|
||||
/>
|
||||
{:else if filterValues[col.key] !== undefined}
|
||||
<select
|
||||
bind:value={filterSelections[col.key]}
|
||||
class={asStringArray(classNameSelect)}
|
||||
>
|
||||
<option value={undefined} />
|
||||
{#each filterValues[col.key] as option}
|
||||
<option value={option.value}>{option.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
{#if showExpandIcon}
|
||||
<th />
|
||||
{/if}
|
||||
</tr>
|
||||
{/if}
|
||||
<slot name="header" {sortOrder} {sortBy}>
|
||||
<tr>
|
||||
{#each columns as col}
|
||||
<th
|
||||
on:click={e => handleClickCol(e, col)}
|
||||
class={asStringArray([
|
||||
col.sortable ? "isSortable" : "",
|
||||
col.headerClass,
|
||||
])}
|
||||
>
|
||||
{col.title}
|
||||
{#if sortBy === col.key}
|
||||
{@html sortOrder === 1 ? iconAsc : iconDesc}
|
||||
{:else if col.sortable}
|
||||
{@html iconSortable}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
{#if showExpandIcon}
|
||||
<th />
|
||||
{/if}
|
||||
</tr>
|
||||
</slot>
|
||||
</thead>
|
||||
|
||||
<tbody class={asStringArray(classNameTbody)}>
|
||||
{#each c_rows as row, n}
|
||||
<slot name="row" {row} {n}>
|
||||
<tr on:click={e => { handleClickRow(e, row); }} class={asStringArray([classNameRow, row.$expanded && classNameRowExpanded])}>
|
||||
{#each columns as col}
|
||||
<td on:click={e => {handleClickCell(e, row, col.key);}} class={asStringArray([col.class, classNameCell])}>
|
||||
{#if col.renderComponent}
|
||||
<svelte:component
|
||||
this={col.renderComponent.component || col.renderComponent}
|
||||
{...col.renderComponent.props || {}}
|
||||
{row}
|
||||
{col}
|
||||
/>
|
||||
{:else}
|
||||
{@html col.renderValue ? col.renderValue(row) : col.value(row)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if showExpandIcon}
|
||||
<td on:click={e => handleClickExpand(e, row)} class={asStringArray(["isClickable", classNameCellExpand])}>
|
||||
{@html row.$expanded ? iconExpand : iconExpanded}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{#if row.$expanded}
|
||||
<tr class={asStringArray(classNameExpandedContent)}>
|
||||
<td {colspan}>
|
||||
<slot name="expanded" {row} {n} />
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</slot>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
.isSortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.isClickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr th select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
66
imports/ui/TestTable.svelte
Normal file
66
imports/ui/TestTable.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
|
||||
<script>
|
||||
import {Route, router, meta} from 'tinro';
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import FlexTable from "./FlexTable.svelte";
|
||||
import {useTracker} from "meteor/rdb:svelte-meteor-data";
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "_id",
|
||||
title: "ID",
|
||||
value: v => v._id,
|
||||
minWidth: 20,
|
||||
weight: 1,
|
||||
}, {
|
||||
key: "text",
|
||||
title: "Text",
|
||||
value: v => v.text,
|
||||
minWidth: 100,
|
||||
weight: 1,
|
||||
}
|
||||
];
|
||||
|
||||
let rows = writable([
|
||||
{
|
||||
_id: "1",
|
||||
text: "A"
|
||||
},
|
||||
{
|
||||
_id: "2",
|
||||
text: "B"
|
||||
},
|
||||
{
|
||||
_id: "3",
|
||||
text: "C"
|
||||
},
|
||||
{
|
||||
_id: "4",
|
||||
text: "D"
|
||||
},
|
||||
]);
|
||||
|
||||
const getRowKey = (row) => row._id;
|
||||
let edited = writable(null);
|
||||
let text = "";
|
||||
const addRow = () => {
|
||||
$rows[$rows.length] = {_id: "" + ($rows.length + 1), text};
|
||||
text = "";
|
||||
}
|
||||
</script>
|
||||
<Route path="/">
|
||||
<div class="container">
|
||||
<div class="row col-12 table">
|
||||
<FlexTable columns="{columns}" bind:rows="{$rows}" rowKey="{getRowKey}" edited="{edited}">
|
||||
My Editor....
|
||||
</FlexTable>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="row col-12">
|
||||
<input type="text" bind:value={text}/>
|
||||
<button type="button" on:click={addRow}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
181
imports/ui/TestUsers.svelte
Normal file
181
imports/ui/TestUsers.svelte
Normal file
@@ -0,0 +1,181 @@
|
||||
<script>
|
||||
export let rows;
|
||||
export let columns;
|
||||
export let rowKey;
|
||||
export let edited;
|
||||
|
||||
// Setup a width for each column.
|
||||
columns.forEach(column => {
|
||||
let min = column.minWidth ? Math.max(10, column.minWidth) : 10;
|
||||
let weight = column.weight ? Math.max(1, column.weight) : 1;
|
||||
column.width = 'minmax(' + min + 'px, ' + weight + 'fr)';
|
||||
});
|
||||
let gridTemplateColumns = columns.map(({width}) => width).join(' ');
|
||||
|
||||
let headerBeingResized = null;
|
||||
let horizontalScrollOffset = 0;
|
||||
const initResize = ({target}) => {
|
||||
headerBeingResized = target.parentNode;
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', completeResize);
|
||||
headerBeingResized.classList.add('header--being-resized');
|
||||
};
|
||||
const completeResize = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', completeResize);
|
||||
headerBeingResized.classList.remove('header--being-resized');
|
||||
headerBeingResized = null;
|
||||
};
|
||||
const onMouseMove = e => {
|
||||
try {
|
||||
// Calculate the desired width.
|
||||
horizontalScrollOffset = document.documentElement.scrollLeft;
|
||||
let parentX = Math.round(headerBeingResized.getBoundingClientRect().x);
|
||||
const width = horizontalScrollOffset + (e.clientX - parentX);
|
||||
// Update the column object with the new size value.
|
||||
const column = columns.find(({element}) => element === headerBeingResized);
|
||||
column.width = Math.max(column.minWidth, width) + "px";
|
||||
// Ensure all the column widths are converted to fixed sizes.
|
||||
columns.forEach((column, index) => {
|
||||
if((index < columns.length - 1) && (column.width.startsWith('minmax'))) {
|
||||
column.width = parseInt(column.element.clientWidth, 10) + 'px';
|
||||
}
|
||||
});
|
||||
// Render the new column sizes.
|
||||
gridTemplateColumns = columns.map(({width}) => width).join(' ');
|
||||
} catch(e) {console.log(e);}
|
||||
}
|
||||
|
||||
let selectedRowElement = null;
|
||||
const selectRow = (e, row) => {
|
||||
let element = e.target;
|
||||
|
||||
while(element && element.nodeName !== "TR") element = element.parentNode;
|
||||
|
||||
if(selectedRowElement) {
|
||||
selectedRowElement.classList.remove('selected');
|
||||
}
|
||||
|
||||
selectedRowElement = element;
|
||||
element.classList.add('selected');
|
||||
}
|
||||
|
||||
let editorContainer;
|
||||
const editRow = (e, row) => {
|
||||
let element = e.target;
|
||||
while(element && element.nodeName !== "TR") element = element.parentNode;
|
||||
let editor = element.querySelector('.editor');
|
||||
|
||||
// Save the edited row so the editor has access to it.
|
||||
$edited = row;
|
||||
|
||||
if(editor) {
|
||||
editor.appendChild(editorContainer);
|
||||
}
|
||||
editorContainer.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={editorContainer} class="hidden"><slot>Slot</slot></div>
|
||||
<table style="--grid-template-columns: {gridTemplateColumns}">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th bind:this={column.element}>{column.title} <span class="resize-handle" on:mousedown={initResize}></span></th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $rows as row (rowKey(row))}
|
||||
<!-- data-key="{rowKey(row)}"-->
|
||||
<tr class:hidden={row === $edited} on:mousedown={(e) => selectRow(e, row)} on:dblclick={(e) => editRow(e, row)}>
|
||||
{#each columns as column}
|
||||
<td>{column.value(row)}</td>
|
||||
{/each}
|
||||
<td class="editor"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<button on:click={() => {$edited = null}} type="button">Stop Editing</button>
|
||||
|
||||
<style>
|
||||
table {
|
||||
width: auto;
|
||||
-webkit-box-flex: 1;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
border-collapse: collapse;
|
||||
grid-template-columns: var(--grid-template-columns);
|
||||
}
|
||||
thead, tbody, tr {
|
||||
display: contents;
|
||||
}
|
||||
th, td {
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
th {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #5cb85c;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
position: relative;
|
||||
}
|
||||
th:last-child {
|
||||
border: 0;
|
||||
}
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: black;
|
||||
opacity: 0;
|
||||
width: 3px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
th:last-child .resize-handle {
|
||||
display: none;
|
||||
}
|
||||
.resize-handle:hover, .header--being-resized .resize-handle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
th:hover .resize-handle {
|
||||
opacity: 0.3;
|
||||
}
|
||||
td {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
color: #808080;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background: #f8f6ff;
|
||||
}
|
||||
:global(.selected), :global(.selected) > td {
|
||||
background-color: yellow;
|
||||
}
|
||||
.editor {
|
||||
grid-column: 1 / 4;
|
||||
display: none;
|
||||
}
|
||||
/*:global(td.hidden) {*/
|
||||
/* display: none;*/
|
||||
/*}*/
|
||||
:global(tr.hidden) > td:not(.editor) {
|
||||
display: none !important;
|
||||
}
|
||||
:global(tr.hidden) > td.editor {
|
||||
display: block !important;
|
||||
}
|
||||
:global(div.hidden) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
191
imports/ui/Users.svelte
Normal file
191
imports/ui/Users.svelte
Normal file
@@ -0,0 +1,191 @@
|
||||
|
||||
<script>
|
||||
import {Route, router, meta} from 'tinro';
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import FlexTable from "./FlexTable.svelte";
|
||||
import {useTracker} from "meteor/rdb:svelte-meteor-data";
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "_id",
|
||||
title: "ID",
|
||||
value: v => v._id,
|
||||
minWidth: 20,
|
||||
weight: 1,
|
||||
cls: "id",
|
||||
}, {
|
||||
key: "name",
|
||||
title: "Name",
|
||||
value: v => v.profile.name,
|
||||
minWidth: 100,
|
||||
weight: 1,
|
||||
cls: "name",
|
||||
}, {
|
||||
key: "roles",
|
||||
title: "Roles",
|
||||
value: user => {
|
||||
return Roles.getRolesForUser(user, {anyScope: true});
|
||||
},
|
||||
minWidth: 150,
|
||||
weight: 2,
|
||||
cls: "roles",
|
||||
}
|
||||
];
|
||||
|
||||
const getRowKey = user => {return user._id;}
|
||||
|
||||
function changeColWidth() {
|
||||
columns[0].width = '200px';
|
||||
}
|
||||
|
||||
const editRow = (row) => {
|
||||
//TODO: Setup the editor for the given row.
|
||||
}
|
||||
let edited = writable(null);
|
||||
let editedPermissions = null;
|
||||
|
||||
$: rows = Meteor.users.find({});
|
||||
|
||||
edited.subscribe((value) => {
|
||||
if(value) {
|
||||
editedPermissions = {
|
||||
isAdmin: Roles.userIsInRole(value, "admin", {anyScope: true}),
|
||||
laptopManagement: Roles.userIsInRole(value, "laptop-management", {anyScope: true}),
|
||||
}
|
||||
}
|
||||
});
|
||||
const applyChanges = () => {
|
||||
let roles = [];
|
||||
|
||||
if(editedPermissions.isAdmin) {
|
||||
roles.push('admin');
|
||||
}
|
||||
else {
|
||||
if(editedPermissions.laptopManagement) {
|
||||
roles.push('laptop-management');
|
||||
}
|
||||
}
|
||||
|
||||
Meteor.call("users.setUserRoles", $edited._id, roles);
|
||||
edited.set(null);
|
||||
}
|
||||
const rejectChanges = () => {
|
||||
edited.set(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Route path="/" let:meta>
|
||||
<div class="container">
|
||||
<div class="row col-12 table">
|
||||
{#await Promise.all([Meteor.subscribe('allUsers'), Meteor.subscribe('allRoleAssignments')])}
|
||||
Loading...
|
||||
{:then allUsers}
|
||||
<FlexTable bind:rows={rows} columns="{columns}" rowKey="{getRowKey}" bind:edited="{edited}">
|
||||
{#if editedPermissions}
|
||||
<div class="editorContainer">
|
||||
<label style="grid-column: 1/4; font-weight: 800; border-bottom: 2px solid #888; margin-bottom: 0.5rem">{$edited.profile.name}</label>
|
||||
<label class="checkbox" style="grid-column: 1/4;"><input type="checkbox" bind:checked="{editedPermissions.isAdmin}" style="--form-control-color: black"/> Administrator</label>
|
||||
<div class="insetPermissions">
|
||||
<label class="checkbox"><input type="checkbox" disabled="{editedPermissions.isAdmin}" bind:checked="{editedPermissions.laptopManagement}" style="--form-control-color: black"/> Laptop Management</label>
|
||||
</div>
|
||||
<button type="button" style="grid-column: 2/2;" class="button accept-button" on:click={applyChanges}> </button>
|
||||
<button type="button" style="grid-column: 3/3;" class="button reject-button" on:click={rejectChanges}> </button>
|
||||
</div>
|
||||
{/if}
|
||||
</FlexTable>
|
||||
<button type="button" on:click="{changeColWidth}">Change Width</button>
|
||||
{:catch error}
|
||||
{error.message}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--form-control-disabled: #959495;
|
||||
}
|
||||
.checkbox {
|
||||
display: grid;
|
||||
grid-template-columns: 1em auto;
|
||||
gap: 0.5em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
/* Add if not using autoprefixer */
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* For iOS < 15 to remove gradient background */
|
||||
background-color: #fff;
|
||||
/* Not removed via appearance */
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: currentColor;
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
border: 0.15em solid currentColor;
|
||||
border-radius: 0.15em;
|
||||
/*transform: translateY(-0.075em);*/
|
||||
display: grid;
|
||||
place-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
input[type="checkbox"]::before {
|
||||
content: "";
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
transform: scale(0);
|
||||
transition: 120ms transform ease-in-out;
|
||||
box-shadow: inset 1em 1em var(--form-control-color);
|
||||
/* Windows High Contrast Mode */
|
||||
background-color: CanvasText;
|
||||
/* Make it a check mark shape. */
|
||||
transform-origin: bottom left;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
input[type="checkbox"]:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
input[type="checkbox"]:focus {
|
||||
outline: max(2px, 0.15em) solid currentColor;
|
||||
outline-offset: max(2px, 0.15em);
|
||||
}
|
||||
input[type="checkbox"]:disabled {
|
||||
--form-control-color: var(--form-control-disabled);
|
||||
|
||||
color: var(--form-control-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.editorContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto 2em 2em;
|
||||
}
|
||||
.insetPermissions {
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 1.5rem;
|
||||
display: grid;
|
||||
grid-column: 1/4;
|
||||
}
|
||||
button.button {
|
||||
border: none;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button.accept-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: black;
|
||||
clip-path: polygon(11% 69%, 31% 93%, 94% 8%, 73% 6%, 33% 62%, 12% 40%);
|
||||
}
|
||||
button.reject-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: black;
|
||||
clip-path: polygon(18% 90%, 37% 89%, 50% 64%, 62% 89%, 81% 88%, 59% 44%, 82% 13%, 62% 12%, 49% 35%, 28% 12%, 11% 12%, 37% 43%);
|
||||
}
|
||||
</style>
|
||||
109
imports/ui/temp.svelte
Normal file
109
imports/ui/temp.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
|
||||
<script>
|
||||
import {Meteor} from "meteor/meteor";
|
||||
import {Route, router} from 'tinro';
|
||||
import {useTracker} from 'meteor/rdb:svelte-meteor-data';
|
||||
import {Roles} from 'meteor/alanning:roles';
|
||||
import Chromebooks from './Chromebooks.svelte';
|
||||
import Users from './Users.svelte';
|
||||
import ListUsers from './ListUsers.svelte';
|
||||
import Admin from './Admin.svelte';
|
||||
import Announcer from './Announcer.svelte';
|
||||
|
||||
// When the URL changes, run the code... in this case to scroll to the top.
|
||||
router.subscribe(_ => window.scrollTo(0, 0));
|
||||
|
||||
$: currentUser = useTracker(() => Meteor.user());
|
||||
$: canManageLaptops = false;
|
||||
$: isAdmin = false;
|
||||
|
||||
Tracker.autorun(() => {
|
||||
// For some reason currentUser is always null here, and is not reactive (user changes and this does not get re-called).
|
||||
let user = Meteor.user();
|
||||
canManageLaptops = user && Roles.userIsInRole(user._id, 'laptop-management', 'global');
|
||||
isAdmin = user && Roles.userIsInRole(user._id, 'admin', 'global');
|
||||
});
|
||||
function performLogin() {
|
||||
//Login style can be "popup" or "redirect". I am not sure we need to request and offline token.
|
||||
Meteor.loginWithGoogle({loginStyle: "popup", requestOfflineToken: true}, (err) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
} else {
|
||||
//console.log("Logged in");
|
||||
}
|
||||
})
|
||||
}
|
||||
function performLogout() {
|
||||
Meteor.logout();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Announcer/>
|
||||
|
||||
<div class="container">
|
||||
<header class="row">
|
||||
<div class="col-12 logoContainer">
|
||||
<img class="logo" src="/images/logo.svg"/>
|
||||
<div class="login">
|
||||
{#if !$currentUser}
|
||||
<button type="button" role="button" on:click={performLogin}>Login</button>
|
||||
{:else}
|
||||
<button type="button" role="button" on:click={performLogout}>Logout</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 center" style="margin-bottom: 0"><h1 style="margin-bottom: 0">District Central</h1></div>
|
||||
<div class="col-12 center">
|
||||
<div class="nav-separator"></div>
|
||||
</div>
|
||||
<nav class="col-12 center">
|
||||
<a href="/">Home</a>
|
||||
{#if canManageLaptops}
|
||||
<a href="/chromebooks">Chromebooks</a>
|
||||
{/if}
|
||||
{#if canManageLaptops}
|
||||
<a href="/users">Users</a>
|
||||
{/if}
|
||||
{#if isAdmin}
|
||||
<a href="/admin">Admin</a>
|
||||
{/if}
|
||||
<!-- <a href="/TestTable">Test</a>-->
|
||||
<!-- <a href="/ListUsers">List Users</a>-->
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<Route path="/">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
TODO: Some statistics and such.
|
||||
</div>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/ListUsers">
|
||||
<ListUsers/>
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
{#if isAdmin}
|
||||
<Admin/>
|
||||
{/if}
|
||||
</Route>
|
||||
<Route path="/chromebooks/*">
|
||||
{#if canManageLaptops}
|
||||
<Chromebooks/>
|
||||
{:else}
|
||||
<!-- User not authorized to use this UI. Don't render anything because it is likely the user is still loading and will have access in a moment. -->
|
||||
{/if}
|
||||
</Route>
|
||||
<Route path="/users/*">
|
||||
{#if isAdmin}
|
||||
<Users/>
|
||||
{:else}
|
||||
<!-- User not authorized to use this UI. Don't render anything because it is likely the user is still loading and will have access in a moment. -->
|
||||
{/if}
|
||||
</Route>
|
||||
|
||||
<style>
|
||||
...
|
||||
</style>
|
||||
Reference in New Issue
Block a user