Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions frontend/src/ClassDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ let showSummary = false;
<div class="card mb-2" style="position: initial">
<div class="card-header p-0">
<div class="float-end p-2" style="display: flex; align-items: center;">
<a href="/task/add/{clazz.subject_abbr}" use:link title="Assign new task">
<a href="/task/add/{clazz.subject_abbr}" title="Assign new task">
<span class="iconify" data-icon="bx:bx-calendar-plus"></span>
</a>
<button
Expand Down Expand Up @@ -223,7 +223,7 @@ let showSummary = false;
</a>
<div class="more-content border shadow rounded bg-body p-1">
{assignment.name}
<a use:link={`/task/edit/${assignment.task_id}`} title="Edit"
<a href={`/task/edit/${assignment.task_id}`} title="Edit"
><span class="iconify" data-icon="clarity:edit-solid"></span></a>
<div style="display: flex; align-items: center;">
<a href={assignment.plagcheck_link} title="Plagiarism check"
Expand Down
170 changes: 170 additions & 0 deletions frontend/src/Teacher/EditTask/AutoCompleteTaskPath.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<script setup lang="ts">
/**
* Component displays an input field that autocomplete path based on subject and username.
* It also allows user to copy entered path in SSH/RSYNC format to clipboard.
*/

import { user as userSvelte } from '../../global.js';
import { clickOutside } from '../../utilities/clickOutside';
import { User } from '../../utilities/SvelteStoreTypes';
import { useReadableSvelteStore } from '../../utilities/useSvelteStoreInVue';
import { defineModel, onMounted, computed, ref, watch, defineEmits } from 'vue';
import CopyToClipboard from './CopyToClipboard.vue';

/**
* @prop {string} subject - subject code used to get autocomplete hints
* @prop {(task_id: number) => void} onChange - function called once user select item from list
*/
let { subject, onChange } = defineProps<{
subject: string;
onChange: (task_id: number) => void;
}>();

const vClickOutside = clickOutside;

const clickEmit = defineEmits<{
(e: 'click'): void;
}>();

/**
* @model
* @type string
* Model is variable containing actual path
*/
const path = defineModel<string>();

interface TaskList {
id: number;
title: string;
path: string;
subject: string;
date: Date;
link: string;
}

const user = useReadableSvelteStore<User>(userSvelte);

const items = ref<TaskList[]>([]);
const selectedId = ref<number | null>(null);
let focused = ref<boolean>(false);
const highlighted_row = ref<number>(-1);

const filtered = computed(() =>
items.value.filter(
(i) =>
i['path'].toLowerCase().includes(path.value.toLowerCase()) &&
i['path'].toLowerCase() != path.value.toLowerCase()
)
);

onMounted(async () => {
// Load last 100 tasks of the given subject.
// Hopefully they will contain some useful paths to autocomplete :)
let res = await fetch(`/api/task-list/${subject}?sort=desc`);
res = await res.json();
items.value = res['tasks'];
});

// https://stackoverflow.com/a/6969486/1107768
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}

function keyup(e: KeyboardEvent) {
if (e.key == 'Enter' && highlighted_row.value >= 0) {
focused.value = false;
selectedId.value = filtered.value[highlighted_row.value].id;
path.value = filtered.value[highlighted_row.value].path;

let input: HTMLInputElement = e.target as HTMLInputElement;
input.blur();
} else if (e.key == 'ArrowUp' && highlighted_row.value >= 0) {
highlighted_row.value = Math.max(0, highlighted_row.value - 1);
} else if (e.key == 'ArrowDown') {
highlighted_row.value = Math.min(filtered.value.length - 1, highlighted_row.value + 1);
}
}

watch(selectedId, () => {
if (selectedId.value) onChange(selectedId.value);
});
</script>

<template>
<div v-click-outside="() => (focused = false)" class="form-control">
<div class="input-group mb-1">
<input
v-model="path"
class="form-control"
required
placeholder="Task directory"
@focus="focused = true"
@click="() => clickEmit('click')"
@keyup="keyup"
/>
<span class="btn btn-sm btn-outline-secondary">
<CopyToClipboard
:content="`${user.username.toLowerCase()}@kelvin.cs.vsb.cz:/srv/kelvin/kelvin/tasks/${path}`"
title="Copy path for scp/rsync to the clipboard"
>path</CopyToClipboard
>
</span>
<span class="btn btn-sm btn-outline-secondary">
<CopyToClipboard
:content="`ssh -t ${user.username.toLowerCase()}@kelvin.cs.vsb.cz 'cd /srv/kelvin/kelvin/tasks/${path} && exec bash'`"
title="Copy ssh command to the clipboard"
>ssh</CopyToClipboard
>
</span>
</div>

<ul v-if="filtered.length && focused">
<li
v-for="(item, i) in filtered"
:key="i"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not ok, because the index i will be reused for different items once filtered changes. Let's remove the key altogether, since there is no inner state of the rendered component.

:class="{ highlight: highlighted_row == i }"
@click="
() => {
path = item.path;
selectedId = item.id;
}
"
v-html="

Check warning on line 132 in frontend/src/Teacher/EditTask/AutoCompleteTaskPath.vue

View workflow job for this annotation

GitHub Actions / test-frontend

'v-html' directive can lead to XSS attack
item.path.replace(new RegExp('(' + escapeRegExp(path) + ')', 'gi'), '<strong>$1</strong>')
"
></li>
</ul>
</div>
</template>

<style scoped>
.form-control {
padding-bottom: 0;
}

input {
width: 100%;
padding-bottom: 0;
padding-top: 0;
outline: 0;
border: 0;
}

ul {
background: white;
border: 1px solid rgb(206, 212, 218);
max-height: 200px;
overflow-y: auto;
list-style: none;
padding-left: 0;
position: absolute;
width: 100%;
z-index: 3;
}

li:hover,
li.highlight {
background: #5bc0de;
cursor: pointer;
}
</style>
82 changes: 82 additions & 0 deletions frontend/src/Teacher/EditTask/CopyToClipboard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* Displays a button with given text and title; once clicked it copies content to clipboard
* and show a small animation informing user about the action
*/
import { ref } from 'vue';

/**
* @prop {string} content - content to copy to clipboard
* @prop {string} title - appears when the user moves the mouse over the button
*/
let { content, title = 'Copy to clipboard' } = defineProps<{
content?: string;
title: string;
}>();

/**
* Pop up message coordinates
*/
interface PopUp {
left: number;
top: number;
}

const tooltip = ref<PopUp | null>(null);

function copy(e: MouseEvent): void {
navigator.clipboard.writeText(content);

let spanElement: HTMLElement = e.target as HTMLElement;
let container: HTMLElement = spanElement.closest('.tooltip-container') as HTMLElement;
tooltip.value = {
left: container.offsetLeft + container.offsetWidth,
top: container.offsetTop - 5
};

setTimeout(() => (tooltip.value = null), 1500);
}
</script>

<template>
<span style="position: relative">
<span class="tooltip-container" :title="title" @click="copy">
<slot></slot>
</span>
<Transition name="fade">
<div
v-if="tooltip"
class="tooltip bs-tooltip-right show d-flex align-items-center"
role="tooltip"
:style="{ left: tooltip.left + 'px', top: tooltip.top + 'px' }"
>
<div class="popover-arrow"></div>
<div class="tooltip-inner">Copied!</div>
</div>
</Transition>
</span>
</template>

<style scoped>
span {
cursor: pointer;
}

.fade-enter-active {
transition: opacity 2s ease;
}

.fade-leave-active {
transition: opacity 0.1s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
Loading
Loading