restructure presentation layer

- Move most GUI related code to /presentation
- Move components to /components (separate from bootstrap and style)
- Move shared components helpers to /components/shared
- Rename Bootstrapping to bootstrapping to enforce same naming
  convention in /presentation
This commit is contained in:
undergroundwires
2021-03-07 19:33:05 +01:00
parent 646db90585
commit f3c7413f52
67 changed files with 100 additions and 71 deletions

View File

@@ -0,0 +1,72 @@
<template>
<div id="app">
<div class="wrapper">
<TheHeader class="row" />
<TheSearchBar class="row" />
<TheScriptArea class="row" />
<TheCodeButtons class="row code-buttons" />
<TheFooter />
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import TheHeader from '@/presentation/components/TheHeader.vue';
import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
@Component({
components: {
TheHeader,
TheCodeButtons,
TheScriptArea,
TheSearchBar,
TheFooter,
},
})
export default class App extends Vue {
}
</script>
<style lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
@import "@/presentation/styles/media.scss";
* {
box-sizing: border-box;
}
body {
background: $light-gray;
font-family: $main-font;
color: $slate;
}
#app {
margin-right: auto;
margin-left: auto;
max-width: 1600px;
.wrapper {
margin: 0% 2% 0% 2%;
background-color: white;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.06);
padding: 2%;
display:flex;
flex-direction: column;
.row {
margin-bottom: 10px;
}
.code-buttons {
padding-bottom: 10px;
}
}
}
@import "@/presentation/styles/tooltip.scss";
@import "@/presentation/styles/tree.scss";
</style>

View File

@@ -0,0 +1,55 @@
<template>
<span class="code-wrapper">
<span class="dollar">$</span>
<code><slot></slot></code>
<font-awesome-icon
class="copy-button"
:icon="['fas', 'copy']"
@click="copyCode"
v-tooltip.top-center="'Copy'"
/>
</span>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Clipboard } from '@/infrastructure/Clipboard';
@Component
export default class Code extends Vue {
public copyCode(): void {
const code = this.$slots.default[0].text;
Clipboard.copyText(code);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
.code-wrapper {
white-space: nowrap;
justify-content: space-between;
font-family: $normal-font;
background-color: $slate;
color: $light-gray;
padding-left: 0.3rem;
padding-right: 0.3rem;
.dollar {
margin-right: 0.5rem;
font-size: 0.8rem;
user-select: none;
}
.copy-button {
margin-left: 1rem;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
code {
font-size: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<button class="button" @click="onClicked">
<font-awesome-icon
class="button__icon"
:icon="[iconPrefix, iconName]" size="2x" />
<div class="button__text">{{text}}</div>
</button>
</template>
<script lang="ts">
import { Component, Prop, Emit, Vue } from 'vue-property-decorator';
@Component
export default class IconButton extends Vue {
@Prop() public text!: number;
@Prop() public iconPrefix!: string;
@Prop() public iconName!: string;
@Emit('click')
public onClicked() {
return;
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
.button {
display: flex;
flex-direction: column;
align-items: center;
background-color: $accent;
border: none;
color: $white;
padding:20px;
transition-duration: 0.4s;
overflow: hidden;
box-shadow: 0 3px 9px $dark-slate;
border-radius: 4px;
cursor: pointer;
// border: 0.1em solid $slate;
// border-radius: 80px;
// padding: 0.5em;
width: 10%;
min-width: 90px;
&:hover {
background: $white;
box-shadow: 0px 2px 10px 5px $accent;
color: $black;
}
&:hover>&__text {
display: block;
}
&:hover>&__icon {
display: none;
}
&__text {
display: none;
font-family: $artistic-font;
font-size: 1.5em;
color: $gray;
font-weight: 500;
line-height: 1.1;
}
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="instructions">
<p>
Since you're using online version of {{ this.appName }}, you will need to do additional steps after downloading the file to execute your script on macOS:
</p>
<p>
<ol>
<li>
<span>Download the file</span>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="'You should be prompted to save the script file now, otherwise try to download it again'"
/>
</li>
<li>
<span>Open terminal</span>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="'Type Terminal into Spotlight or open from the Applications -> Utilities folder'"
/>
</li>
<li>
<span>Navigate to the folder where you downloaded the file e.g.:</span>
<div>
<Code>cd ~/Downloads</Code>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="
'Press on Enter/Return key after running the command.<br/>' +
'If the file is not downloaded on Downloads folder, change `Downloads` to path where the file is downloaded.<br/>' +
' `cd` will change the current folder.<br/>' +
' `~` is the user home directory.'"
/>
</div>
</li>
<li>
<span>Give the file execute permissions:</span>
<div>
<Code>chmod +x {{ this.fileName }}</Code>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="
'Press on Enter/Return key after running the command.<br/>' +
'It will make the file executable.'"
/>
</div>
</li>
<li>
<span>Execute the file:</span>
<div>
<Code>./{{ this.fileName }}</Code>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="'Alternatively you can double click on the file'"
/>
</div>
</li>
<li>
<span>If asked, enter your administrator password</span>
<font-awesome-icon
class="explanation"
:icon="['fas', 'info-circle']"
v-tooltip.top-center="
'Press on Enter/Return key after typing your password<br/>' +
'Your password will not be shown by default.<br/>' +
'Administor privileges are required to configure OS.'"
/>
</li>
</ol>
</p>
<p>
Or download the <a :href="this.macOsDownloadUrl">offline version</a> to run your scripts directly to skip these steps.
</p>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import Code from './Code.vue';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component({
components: {
Code,
},
})
export default class MacOsInstructions extends Vue {
@Prop() public fileName: string;
public appName = '';
public macOsDownloadUrl = '';
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.appName = app.info.name;
this.macOsDownloadUrl = app.info.getDownloadUrl(OperatingSystem.macOS);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
li {
margin: 10px 0;
}
.explanation {
margin-left: 0.5em;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div class="container" v-if="hasCode">
<IconButton
v-if="this.canRun"
text="Run"
v-on:click="executeCodeAsync"
icon-prefix="fas" icon-name="play">
</IconButton>
<IconButton
:text="this.isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCodeAsync"
icon-prefix="fas"
:icon-name="this.isDesktopVersion ? 'save' : 'file-download'">
</IconButton>
<IconButton
text="Copy"
v-on:click="copyCodeAsync"
icon-prefix="fas" icon-name="copy">
</IconButton>
<modal :name="macOsModalName" height="auto" :scrollable="true" :adaptive="true"
v-if="this.isMacOsCollection">
<div class="modal">
<div class="modal__content">
<MacOsInstructions :fileName="this.fileName" />
</div>
<div class="modal__close-button">
<font-awesome-icon :icon="['fas', 'times']" @click="$modal.hide(macOsModalName)"/>
</div>
</div>
</modal>
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import IconButton from './IconButton.vue';
import MacOsInstructions from './MacOsInstructions.vue';
import { Environment } from '@/application/Environment/Environment';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { runCodeAsync } from '@/infrastructure/CodeRunner';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
@Component({
components: {
IconButton,
MacOsInstructions,
},
})
export default class TheCodeButtons extends StatefulVue {
public readonly macOsModalName = 'macos-instructions';
public readonly isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
public canRun = false;
public hasCode = false;
public isMacOsCollection = false;
public fileName = '';
public async copyCodeAsync() {
const code = await this.getCurrentCodeAsync();
Clipboard.copyText(code.current);
}
public async saveCodeAsync() {
const context = await this.getCurrentContextAsync();
saveCode(this.fileName, context.state);
if (this.isMacOsCollection) {
this.$modal.show(this.macOsModalName);
}
}
public async executeCodeAsync() {
const context = await this.getCurrentContextAsync();
await executeCodeAsync(context);
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.canRun = this.isDesktopVersion && newState.collection.os === Environment.CurrentEnvironment.os;
this.isMacOsCollection = newState.collection.os === OperatingSystem.macOS;
this.fileName = buildFileName(newState.collection.scripting);
this.react(newState.code);
}
private async getCurrentCodeAsync(): Promise<IApplicationCode> {
const context = await this.getCurrentContextAsync();
const code = context.state.code;
return code;
}
private async react(code: IApplicationCode) {
this.hasCode = code.current && code.current.length > 0;
this.events.unsubscribeAll();
this.events.register(code.changed.on((newCode) => {
this.hasCode = newCode && newCode.code.length > 0;
}));
}
}
function saveCode(fileName: string, state: ICategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
async function executeCodeAsync(context: IApplicationContext) {
await runCodeAsync(
/*code*/ context.state.code.current,
/*appName*/ context.app.info.name,
/*fileExtension*/ context.state.collection.scripting.fileExtension,
);
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
.container {
display: flex;
flex-direction: row;
justify-content: center;
}
.container > * + * {
margin-left: 30px;
}
.modal {
font-family: $normal-font;
margin-bottom: 10px;
display: flex;
flex-direction: row;
&__content {
width: 100%;
margin: 5%;
}
&__close-button {
width: auto;
font-size: 1.5em;
margin-right:0.25em;
align-self: flex-start;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<Responsive v-on:sizeChanged="sizeChanged()">
<div
:id="editorId"
class="code-area"
></div>
</Responsive>
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import ace from 'ace-builds';
import 'ace-builds/webpack-resolver';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
import Responsive from '@/presentation/components/Shared/Responsive.vue';
@Component({
components: {
Responsive,
},
})
export default class TheCodeArea extends StatefulVue {
public readonly editorId = 'codeEditor';
private editor!: ace.Ace.Editor;
private currentMarkerId?: number;
@Prop() private theme!: string;
public destroyed() {
this.destroyEditor();
}
public sizeChanged() {
if (this.editor) {
this.editor.resize();
}
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.destroyEditor();
this.editor = initializeEditor(this.theme, this.editorId, newState.collection.scripting.language);
const appCode = newState.code;
this.editor.setValue(appCode.current || getDefaultCode(newState.collection.scripting.language), 1);
this.events.unsubscribeAll();
this.events.register(appCode.changed.on((code) => this.updateCodeAsync(code)));
}
private async updateCodeAsync(event: ICodeChangedEvent) {
this.removeCurrentHighlighting();
if (event.isEmpty()) {
const context = await this.getCurrentContextAsync();
const defaultCode = getDefaultCode(context.state.collection.scripting.language);
this.editor.setValue(defaultCode, 1);
return;
}
this.editor.setValue(event.code, 1);
if (event.addedScripts && event.addedScripts.length) {
this.reactToChanges(event, event.addedScripts);
} else if (event.changedScripts && event.changedScripts.length) {
this.reactToChanges(event, event.changedScripts);
}
}
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
const positions = scripts
.map((script) => event.getScriptPositionInCode(script));
const start = Math.min(
...positions.map((position) => position.startLine),
);
const end = Math.max(
...positions.map((position) => position.endLine),
);
this.scrollToLine(end + 2);
this.highlight(start, end);
}
private highlight(startRow: number, endRow: number) {
const AceRange = ace.require('ace/range').Range;
this.currentMarkerId = this.editor.session.addMarker(
new AceRange(startRow, 0, endRow, 0), 'code-area__highlight', 'fullLine',
);
}
private scrollToLine(row: number) {
const column = this.editor.session.getLine(row).length;
this.editor.gotoLine(row, column, true);
}
private removeCurrentHighlighting() {
if (!this.currentMarkerId) {
return;
}
this.editor.session.removeMarker(this.currentMarkerId);
this.currentMarkerId = undefined;
}
private destroyEditor() {
if (this.editor) {
this.editor.destroy();
this.editor = undefined;
}
}
}
function initializeEditor(theme: string, editorId: string, language: ScriptingLanguage): ace.Ace.Editor {
theme = theme || 'github';
const editor = ace.edit(editorId);
const lang = getLanguage(language);
editor.getSession().setMode(`ace/mode/${lang}`);
editor.setTheme(`ace/theme/${theme}`);
editor.setReadOnly(true);
editor.setAutoScrollEditorIntoView(true);
editor.setShowPrintMargin(false); // hides vertical line
editor.getSession().setUseWrapMode(true); // So code is readable on mobile
return editor;
}
function getLanguage(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile: return 'batchfile';
case ScriptingLanguage.shellscript: return 'sh';
default:
throw new Error('unknown language');
}
}
function getDefaultCode(language: ScriptingLanguage): string {
return new CodeBuilderFactory()
.create(language)
.appendCommentLine('privacy.sexy — 🔐 Enforce privacy & security best-practices on Windows and macOS')
.appendLine()
.appendCommentLine('-- 🤔 How to use')
.appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.')
.appendCommentLine(' 📙 On top left, you can apply predefined selections for privacy level you\'d like.')
.appendCommentLine(' 📙 After you choose any tweak, you can download or copy to execute your script.')
.appendCommentLine(' 📙 Come back regularly to apply latest version for stronger privacy and security.')
.appendLine()
.appendCommentLine('-- 🧐 Why privacy.sexy')
.appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other software on it.')
.appendCommentLine(' ✔️ No need to run any compiled software on your system, just run the generated scripts.')
.appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.')
.appendCommentLine(' ✔️ Open-source and free (both free as in beer and free as in speech).')
.toString();
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
::v-deep .code-area {
min-height: 200px;
width: 100%;
height: 100%;
overflow: auto;
&__highlight {
background-color: $accent;
opacity: 0.2; // having procent fails in production (minified) build
position: absolute;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<Responsive v-on:widthChanged="width = $event">
<!-- <div id="responsivity-debug">
Width: {{ width || 'undefined' }}
Size: <span v-if="width <= 500">small</span><span v-if="width > 500 && width < 750">medium</span><span v-if="width >= 750">big</span>
</div> -->
<div v-if="categoryIds != null && categoryIds.length > 0" class="cards">
<CardListItem
class="card"
v-bind:class="{
'small-screen': width <= 500,
'medium-screen': width > 500 && width < 750,
'big-screen': width >= 750
}"
v-for="categoryId of categoryIds"
:data-category="categoryId"
v-bind:key="categoryId"
:categoryId="categoryId"
:activeCategoryId="activeCategoryId"
v-on:selected="onSelected(categoryId, $event)">
</CardListItem>
</div>
<div v-else class="error">Something went bad 😢</div>
</Responsive>
</template>
<script lang="ts">
import CardListItem from './CardListItem.vue';
import Responsive from '@/presentation/components/Shared/Responsive.vue';
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { ICategory } from '@/domain/ICategory';
import { hasDirective } from './NonCollapsingDirective';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
@Component({
components: {
CardListItem,
Responsive,
},
})
export default class CardList extends StatefulVue {
public width: number = 0;
public categoryIds: number[] = [];
public activeCategoryId?: number = null;
public created() {
this.onOutsideOfActiveCardClicked((element) => {
if (hasDirective(element)) {
return;
}
this.activeCategoryId = null;
});
}
public onSelected(categoryId: number, isExpanded: boolean) {
this.activeCategoryId = isExpanded ? categoryId : undefined;
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
this.setCategories(newState.collection.actions);
this.activeCategoryId = undefined;
}
private setCategories(categories: ReadonlyArray<ICategory>): void {
this.categoryIds = categories.map((category) => category.id);
}
private onOutsideOfActiveCardClicked(callback: (clickedElement: Element) => void) {
const outsideClickListener = (event) => {
if (!this.activeCategoryId) {
return;
}
const element = document.querySelector(`[data-category="${this.activeCategoryId}"]`);
if (element && !element.contains(event.target)) {
callback(event.target);
}
};
document.addEventListener('click', outsideClickListener);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/fonts.scss";
.cards {
display: flex;
flex-flow: row wrap;
font-family: $main-font;
}
.error {
width: 100%;
text-align: center;
font-size: 3.5em;
font-family: $normal-font;
}
</style>

View File

@@ -0,0 +1,267 @@
<template>
<div class="card"
v-on:click="onSelected(!isExpanded)"
v-bind:class="{
'is-collapsed': !isExpanded,
'is-inactive': activeCategoryId && activeCategoryId != categoryId,
'is-expanded': isExpanded
}"
ref="cardElement">
<div class="card__inner">
<span v-if="cardTitle && cardTitle.length > 0">
<span>{{cardTitle}}</span>
</span>
<span v-else>Oh no 😢</span>
<!-- Expand icon -->
<font-awesome-icon :icon="['far', isExpanded ? 'folder-open' : 'folder']" class="card__inner__expand-icon" />
<!-- Indeterminate and full states -->
<div class="card__inner__state-icons">
<font-awesome-icon v-if="isAnyChildSelected && !areAllChildrenSelected" :icon="['fa', 'battery-half']" />
<font-awesome-icon v-if="areAllChildrenSelected" :icon="['fa', 'battery-full']" />
</div>
</div>
<div class="card__expander" v-on:click.stop>
<div class="card__expander__content">
<ScriptsTree :categoryId="categoryId"></ScriptsTree>
</div>
<div class="card__expander__close-button">
<font-awesome-icon :icon="['fas', 'times']" v-on:click="onSelected(false)"/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Watch, Emit } from 'vue-property-decorator';
import ScriptsTree from '@/presentation/components/Scripts/ScriptsTree/ScriptsTree.vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
@Component({
components: {
ScriptsTree,
},
})
export default class CardListItem extends StatefulVue {
@Prop() public categoryId!: number;
@Prop() public activeCategoryId!: number;
public cardTitle = '';
public isExpanded = false;
public isAnyChildSelected = false;
public areAllChildrenSelected = false;
public async mounted() {
const context = await this.getCurrentContextAsync();
this.events.register(context.state.selection.changed.on(
() => this.updateSelectionIndicatorsAsync(this.categoryId)));
await this.updateStateAsync(this.categoryId);
}
@Emit('selected')
public onSelected(isExpanded: boolean) {
this.isExpanded = isExpanded;
}
@Watch('activeCategoryId')
public async onActiveCategoryChanged(value: |number) {
this.isExpanded = value === this.categoryId;
}
@Watch('isExpanded')
public async onExpansionChangedAsync(newValue: number, oldValue: number) {
if (!oldValue && newValue) {
await new Promise((r) => setTimeout(r, 400));
const focusElement = this.$refs.cardElement as HTMLElement;
(focusElement as HTMLElement).scrollIntoView({behavior: 'smooth'});
}
}
@Watch('categoryId')
public async updateStateAsync(value: |number) {
const context = await this.getCurrentContextAsync();
const category = !value ? undefined : context.state.collection.findCategory(value);
this.cardTitle = category ? category.name : undefined;
await this.updateSelectionIndicatorsAsync(value);
}
protected handleCollectionState(): void {
return;
}
private async updateSelectionIndicatorsAsync(categoryId: number) {
const context = await this.getCurrentContextAsync();
const selection = context.state.selection;
const category = context.state.collection.findCategory(categoryId);
this.isAnyChildSelected = category ? selection.isAnySelected(category) : false;
this.areAllChildrenSelected = category ? selection.areAllSelected(category) : false;
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/media.scss";
$card-padding: 30px;
$card-margin: 15px;
$card-line-break-width: 30px;
$arrow-size: 15px;
$expanded-margin-top: 30px;
.card {
margin: 15px;
transition: all 0.2s ease-in-out;
&__inner {
padding: $card-padding $card-padding 0 $card-padding;
position: relative;
cursor: pointer;
background-color: $gray;
color: $light-gray;
font-size: 1.5em;
height: 100%;
text-transform: uppercase;
text-align: center;
transition: all 0.2s ease-in-out;
display:flex;
flex-direction: column;
justify-content: center;
&:hover {
background-color: $accent;
transform: scale(1.05);
}
&:after {
transition: all 0.3s ease-in-out;
}
&__state-icons {
height: $card-padding;
margin-right: -$card-padding;
padding-right: 10px;
display: flex;
justify-content: flex-end;
}
&__expand-icon {
width: 100%;
margin-top: .25em;
vertical-align: middle;
}
}
&__expander {
transition: all 0.2s ease-in-out;
position: relative;
background-color: $slate;
color: $light-gray;
display: flex;
align-items: center;
&__content {
width: 100%;
display: flex;
justify-content: center;
word-break: break-word;
}
&__close-button {
width: auto;
font-size: 1.5em;
align-self: flex-start;
margin-right:0.25em;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
}
&.is-collapsed {
.card__inner {
&:after {
content: "";
opacity: 0;
}
}
.card__expander {
max-height: 0;
min-height: 0;
overflow: hidden;
opacity: 0;
}
}
&.is-expanded {
.card__inner {
height: auto;
background-color: $accent;
&:after { // arrow
content: "";
display: block;
position: absolute;
bottom: calc(-1 * #{$expanded-margin-top});
left: calc(50% - #{$arrow-size});
border-left: #{$arrow-size} solid transparent;
border-right: #{$arrow-size} solid transparent;
border-bottom: #{$arrow-size} solid #333a45;
}
}
.card__expander {
min-height: 200px;
// max-height: 1000px;
// overflow-y: auto;
margin-top: $expanded-margin-top;
opacity: 1;
}
&:hover {
.card__inner {
transform: scale(1);
}
}
}
&.is-inactive {
.card__inner {
pointer-events: none;
height: auto;
opacity: 0.5;
transform: scale(0.95);
}
&:hover {
.card__inner {
background-color: $gray;
transform: scale(1);
}
}
}
}
@mixin adaptive-card($cards-in-row) {
&.card {
width: calc((100% / #{$cards-in-row}) - #{$card-line-break-width});
@for $nth-card from 2 through $cards-in-row {
&:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
.card__expander {
$card-left: -100% * ($nth-card - 1);
$additional-space: $card-line-break-width * ($nth-card - 1);
margin-left: calc(#{$card-left} - #{$additional-space});
}
}
}
// Ensure new line after last row
$card-after-last: $cards-in-row + 1;
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
clear: left;
}
}
.card__expander {
$all-cards-width: 100% * $cards-in-row;
$card-padding: $card-line-break-width * ($cards-in-row - 1);
width: calc(#{$all-cards-width} + #{$card-padding});
}
}
.big-screen { @include adaptive-card(3); }
.medium-screen { @include adaptive-card(2); }
.small-screen { @include adaptive-card(1); }
</style>

View File

@@ -0,0 +1,17 @@
import { DirectiveOptions } from 'vue';
const attributeName = 'data-interaction-does-not-collapse';
export function hasDirective(el: Element): boolean {
if (el.hasAttribute(attributeName)) {
return true;
}
const parent = el.closest(`[${attributeName}]`);
return !!parent;
}
export const NonCollapsing: DirectiveOptions = {
inserted(el: HTMLElement) {
el.setAttribute(attributeName, '');
},
};

View File

@@ -0,0 +1,4 @@
export enum Grouping {
Cards = 1,
None = 0,
}

View File

@@ -0,0 +1,78 @@
<template>
<div class="container">
<span class="part">Group by:</span>
<span class="part">
<span
class="part"
v-bind:class="{ 'disabled': cardsSelected, 'enabled': !cardsSelected}"
@click="groupByCard()">Cards</span>
<span class="part">|</span>
<span class="part"
v-bind:class="{ 'disabled': noneSelected, 'enabled': !noneSelected}"
@click="groupByNone()">None</span>
</span>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Grouping } from './Grouping';
const DefaultGrouping = Grouping.Cards;
@Component
export default class TheGrouper extends Vue {
public cardsSelected = false;
public noneSelected = false;
private currentGrouping: Grouping;
public mounted() {
this.changeGrouping(DefaultGrouping);
}
public groupByCard() {
this.changeGrouping(Grouping.Cards);
}
public groupByNone() {
this.changeGrouping(Grouping.None);
}
private changeGrouping(newGrouping: Grouping) {
if (this.currentGrouping === newGrouping) {
return;
}
this.currentGrouping = newGrouping;
this.cardsSelected = newGrouping === Grouping.Cards;
this.noneSelected = newGrouping === Grouping.None;
this.$emit('groupingChanged', this.currentGrouping);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
.container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
font-family: $normal-font;
.part {
display: flex;
margin-right:5px;
}
}
.enabled {
cursor: pointer;
&:hover {
font-weight:bold;
text-decoration:underline;
}
}
.disabled {
color:$gray;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<span
v-bind:class="{ 'disabled': enabled, 'enabled': !enabled}"
v-non-collapsing
@click="onClicked()">{{label}}</span>
</template>
<script lang="ts">
import { Component, Prop, Emit, Vue } from 'vue-property-decorator';
import { NonCollapsing } from '@/presentation/components/Scripts/Cards/NonCollapsingDirective';
@Component({
directives: { NonCollapsing },
})
export default class SelectableOption extends Vue {
@Prop() public enabled: boolean;
@Prop() public label: string;
@Emit('click') public onClicked() { return; }
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
.enabled {
cursor: pointer;
&:hover {
font-weight:bold;
text-decoration:underline;
}
}
.disabled {
color:$gray;
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="container">
<div class="part">Select:</div>
<div class="part">
<div class="part">
<SelectableOption
label="None"
:enabled="this.currentSelection == SelectionState.None"
@click="selectAsync(SelectionState.None)"
v-tooltip=" 'Deselect all selected scripts.<br/>' +
'💡 Good start to dive deeper into tweaks and select only what you want.'"
/>
</div>
<div class="part"> | </div>
<div class="part">
<SelectableOption
label="Standard"
:enabled="this.currentSelection == SelectionState.Standard"
@click="selectAsync(SelectionState.Standard)"
v-tooltip=" '🛡️ Balanced for privacy and functionality.<br/>' +
'OS and applications will function normally.<br/>' +
'💡 Recommended for everyone'"
/>
</div>
<div class="part"> | </div>
<div class="part">
<SelectableOption
label="Strict"
:enabled="this.currentSelection == SelectionState.Strict"
@click="selectAsync(SelectionState.Strict)"
v-tooltip=" '🚫 Stronger privacy, disables risky functions that may leak your data.<br/>' +
'⚠️ Double check to remove sripts where you would trade functionality for privacy<br/>' +
'💡 Recommended for daily users that prefers more privacy over non-essential functions'"
/>
</div>
<div class="part"> | </div>
<div class="part">
<SelectableOption
label="All"
:enabled="this.currentSelection == SelectionState.All"
@click="selectAsync(SelectionState.All)"
v-tooltip=" '🔒 Strongest privacy, disabling any functionality that may leak your data.<br/>' +
'🛑 Not designed for daily users, it will break important functionalities.<br/>' +
'💡 Only recommended for extreme use-cases like crime labs where no leak is acceptable'"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import SelectableOption from './SelectableOption.vue';
import { IScript } from '@/domain/IScript';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
enum SelectionState {
Standard,
Strict,
All,
None,
Custom,
}
@Component({
components: {
SelectableOption,
},
})
export default class TheSelector extends StatefulVue {
public SelectionState = SelectionState;
public currentSelection = SelectionState.None;
public async selectAsync(type: SelectionState): Promise<void> {
if (this.currentSelection === type) {
return;
}
const context = await this.getCurrentContextAsync();
selectType(context.state, type);
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState): void {
this.updateSelections(newState);
newState.selection.changed.on(() => this.updateSelections(newState));
if (oldState) {
oldState.selection.changed.on(() => this.updateSelections(oldState));
}
}
private updateSelections(state: ICategoryCollectionState) {
this.currentSelection = getCurrentSelectionState(state);
}
}
interface ITypeSelector {
isSelected: (state: ICategoryCollectionState) => boolean;
select: (state: ICategoryCollectionState) => void;
}
const selectors = new Map<SelectionState, ITypeSelector>([
[SelectionState.None, {
select: (state) =>
state.selection.deselectAll(),
isSelected: (state) =>
state.selection.totalSelected === 0,
}],
[SelectionState.Standard, {
select: (state) =>
state.selection.selectOnly(
state.collection.getScriptsByLevel(RecommendationLevel.Standard)),
isSelected: (state) =>
hasAllSelectedLevelOf(RecommendationLevel.Standard, state),
}],
[SelectionState.Strict, {
select: (state) =>
state.selection.selectOnly(state.collection.getScriptsByLevel(RecommendationLevel.Strict)),
isSelected: (state) =>
hasAllSelectedLevelOf(RecommendationLevel.Strict, state),
}],
[SelectionState.All, {
select: (state) =>
state.selection.selectAll(),
isSelected: (state) =>
state.selection.totalSelected === state.collection.totalScripts,
}],
]);
function selectType(state: ICategoryCollectionState, type: SelectionState) {
const selector = selectors.get(type);
selector.select(state);
}
function getCurrentSelectionState(state: ICategoryCollectionState): SelectionState {
for (const [type, selector] of Array.from(selectors.entries())) {
if (selector.isSelected(state)) {
return type;
}
}
return SelectionState.Custom;
}
function hasAllSelectedLevelOf(level: RecommendationLevel, state: ICategoryCollectionState) {
const scripts = state.collection.getScriptsByLevel(level);
const selectedScripts = state.selection.selectedScripts;
return areAllSelected(scripts, selectedScripts);
}
function areAllSelected(
expectedScripts: ReadonlyArray<IScript>,
selection: ReadonlyArray<SelectedScript>): boolean {
selection = selection.filter((selected) => !selected.revert);
if (expectedScripts.length < selection.length) {
return false;
}
const selectedScriptIds = selection.map((script) => script.id).sort();
const expectedScriptIds = expectedScripts.map((script) => script.id).sort();
return selectedScriptIds.every((id, index) => id === expectedScriptIds[index]);
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/fonts.scss";
.container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
.part {
display: flex;
margin-right:5px;
}
font-family: $normal-font;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="container">
<!-- <div>OS:</div> -->
<div class="os-list">
<div v-for="os in this.allOses" :key="os.name">
<span
class="os-name"
v-bind:class="{ 'current': currentOs === os.os }"
v-on:click="changeOsAsync(os.os)">
{{ os.name }}
</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component
export default class TheOsChanger extends StatefulVue {
public allOses: Array<{ name: string, os: OperatingSystem }> = [];
public currentOs: OperatingSystem = OperatingSystem.Unknown;
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.allOses = app.getSupportedOsList()
.map((os) => ({ os, name: renderOsName(os) }));
}
public async changeOsAsync(newOs: OperatingSystem) {
const context = await this.getCurrentContextAsync();
context.changeContext(newOs);
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.currentOs = newState.os;
this.$forceUpdate(); // v-bind:class is not updated otherwise
}
}
function renderOsName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Windows: return 'Windows';
case OperatingSystem.macOS: return 'macOS';
default: throw new RangeError(`Cannot render os name: ${OperatingSystem[os]}`);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/fonts.scss";
@import "@/presentation/styles/colors.scss";
.container {
font-family: $normal-font;
display: flex;
align-items: center;
.os-list {
display: flex;
margin-left: 0.25rem;
div + div::before {
content: "|";
margin-left: 0.5rem;
}
.os-name {
&:not(.current) {
cursor: pointer;
&:hover {
font-weight: bold;
text-decoration: underline;
}
}
&.current {
color: $gray;
}
}
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div id="container">
<TheSelector class="item" />
<TheOsChanger class="item" />
<TheGrouper
class="item"
v-on:groupingChanged="$emit('groupingChanged', $event)"
v-if="!this.isSearching" />
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import TheOsChanger from './TheOsChanger.vue';
import TheSelector from './Selector/TheSelector.vue';
import TheGrouper from './Grouping/TheGrouper.vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
@Component({
components: {
TheSelector,
TheOsChanger,
TheGrouper,
},
})
export default class TheScriptsMenu extends StatefulVue {
public isSearching = false;
private listeners = new Array<IEventSubscription>();
public destroyed() {
this.unsubscribeAll();
}
protected initialize(): void {
return;
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.subscribe(newState);
}
private subscribe(state: ICategoryCollectionState) {
this.listeners.push(state.filter.filterRemoved.on(() => {
this.isSearching = false;
}));
state.filter.filtered.on(() => {
this.isSearching = true;
});
}
private unsubscribeAll() {
this.listeners.forEach((listener) => listener.unsubscribe());
this.listeners.splice(0, this.listeners.length);
}
}
</script>
<style scoped lang="scss">
#container {
display: flex;
flex-wrap: wrap;
.item {
flex: 1;
white-space: nowrap;
display: flex;
justify-content: center;
margin: 0 5px 0 5px;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
}
</style>

View File

@@ -0,0 +1,90 @@
import { ICategory, IScript } from '@/domain/ICategory';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export function parseAllCategories(collection: ICategoryCollection): INode[] | undefined {
const nodes = new Array<INode>();
for (const category of collection.actions) {
const children = parseCategoryRecursively(category);
nodes.push(convertCategoryToNode(category, children));
}
return nodes;
}
export function parseSingleCategory(categoryId: number, collection: ICategoryCollection): INode[] | undefined {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`);
}
const tree = parseCategoryRecursively(category);
return tree;
}
export function getScriptNodeId(script: IScript): string {
return script.id;
}
export function getScriptId(nodeId: string): string {
return nodeId;
}
export function getCategoryId(nodeId: string): number {
return +nodeId;
}
export function getCategoryNodeId(category: ICategory): string {
return `${category.id}`;
}
function parseCategoryRecursively(
parentCategory: ICategory): INode[] {
if (!parentCategory) {
throw new Error('parentCategory is undefined');
}
let nodes = new Array<INode>();
nodes = addCategories(parentCategory.subCategories, nodes);
nodes = addScripts(parentCategory.scripts, nodes);
return nodes;
}
function addScripts(scripts: ReadonlyArray<IScript>, nodes: INode[]): INode[] {
if (!scripts || scripts.length === 0) {
return nodes;
}
for (const script of scripts) {
nodes.push(convertScriptToNode(script));
}
return nodes;
}
function addCategories(categories: ReadonlyArray<ICategory>, nodes: INode[]): INode[] {
if (!categories || categories.length === 0) {
return nodes;
}
for (const category of categories) {
const subCategoryNodes = parseCategoryRecursively(category);
nodes.push(convertCategoryToNode(category, subCategoryNodes));
}
return nodes;
}
function convertCategoryToNode(
category: ICategory, children: readonly INode[]): INode {
return {
id: getCategoryNodeId(category),
type: NodeType.Category,
text: category.name,
children,
documentationUrls: category.documentationUrls,
isReversible: children && children.every((child) => child.isReversible),
};
}
function convertScriptToNode(script: IScript): INode {
return {
id: getScriptNodeId(script),
type: NodeType.Script,
text: script.name,
children: undefined,
documentationUrls: script.documentationUrls,
isReversible: script.canRevert(),
};
}

View File

@@ -0,0 +1,135 @@
<template>
<span id="container">
<span v-if="nodes != null && nodes.length > 0">
<SelectableTree
:initialNodes="nodes"
:selectedNodeIds="selectedNodeIds"
:filterPredicate="filterPredicate"
:filterText="filterText"
v-on:nodeSelected="toggleNodeSelectionAsync($event)"
>
</SelectableTree>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { parseAllCategories, parseSingleCategory, getScriptNodeId,
getCategoryNodeId, getCategoryId, getScriptId } from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
@Component({
components: {
SelectableTree,
},
})
export default class ScriptsTree extends StatefulVue {
@Prop() public categoryId?: number;
public nodes?: ReadonlyArray<INode> = null;
public selectedNodeIds?: ReadonlyArray<string> = [];
public filterText?: string = null;
private filtered?: IFilterResult;
public async toggleNodeSelectionAsync(event: INodeSelectedEvent) {
const context = await this.getCurrentContextAsync();
switch (event.node.type) {
case NodeType.Category:
toggleCategoryNodeSelection(event, context.state);
break;
case NodeType.Script:
toggleScriptNodeSelection(event, context.state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
}
}
@Watch('categoryId', { immediate: true })
public async setNodesAsync(categoryId?: number) {
const context = await this.getCurrentContextAsync();
if (categoryId) {
this.nodes = parseSingleCategory(categoryId, context.state.collection);
} else {
this.nodes = parseAllCategories(context.state.collection);
}
this.selectedNodeIds = context.state.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script));
}
public filterPredicate(node: INode): boolean {
return this.filtered.scriptMatches.some(
(script: IScript) => node.id === getScriptNodeId(script))
|| this.filtered.categoryMatches.some(
(category: ICategory) => node.id === getCategoryNodeId(category));
}
protected async handleCollectionState(newState: ICategoryCollectionState) {
this.setCurrentFilter(newState.filter.currentFilter);
if (!this.categoryId) {
this.nodes = parseAllCategories(newState.collection);
}
this.events.unsubscribeAll();
this.subscribeState(newState);
}
private subscribeState(state: ICategoryCollectionState) {
this.events.register(
state.selection.changed.on(this.handleSelectionChanged),
state.filter.filterRemoved.on(this.handleFilterRemoved),
state.filter.filtered.on(this.handleFiltered),
);
}
private setCurrentFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) {
this.handleFilterRemoved();
} else {
this.handleFiltered(currentFilter);
}
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
this.selectedNodeIds = selectedScripts
.map((node) => node.id);
}
private handleFilterRemoved() {
this.filterText = '';
}
private handleFiltered(result: IFilterResult) {
this.filterText = result.query;
this.filtered = result;
}
}
function toggleCategoryNodeSelection(event: INodeSelectedEvent, state: ICategoryCollectionState): void {
const categoryId = getCategoryId(event.node.id);
if (event.isSelected) {
state.selection.addOrUpdateAllInCategory(categoryId, false);
} else {
state.selection.removeAllInCategory(categoryId);
}
}
function toggleScriptNodeSelection(event: INodeSelectedEvent, state: ICategoryCollectionState): void {
const scriptId = getScriptId(event.node.id);
const actualToggleState = state.selection.isSelected(scriptId);
const targetToggleState = event.isSelected;
if (targetToggleState && !actualToggleState) {
state.selection.addSelectedScript(scriptId, false);
} else if (!targetToggleState && actualToggleState) {
state.selection.removeSelectedScript(scriptId);
}
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,6 @@
import { INode } from './Node/INode';
export interface INodeSelectedEvent {
isSelected: boolean;
node: INode;
}

View File

@@ -0,0 +1,76 @@
declare module 'liquor-tree' {
import { PluginObject } from 'vue';
import { VueClass } from 'vue-class-component/lib/declarations';
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
export interface ILiquorTree {
readonly model: ReadonlyArray<ILiquorTreeExistingNode>;
filter(query: string): void;
clearFilter(): void;
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void;
// getNodeById(id: string): ILiquorTreeExistingNode;
recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void;
}
export interface ICustomLiquorTreeData {
type: number;
documentationUrls: ReadonlyArray<string>;
isReversible: boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
export interface ILiquorTreeNodeState {
checked: boolean;
indeterminate: boolean;
}
export interface ILiquorTreeNode {
id: string;
data: ICustomLiquorTreeData;
children: ReadonlyArray<ILiquorTreeNode> | undefined;
}
/**
* Returned from Node tree view events.
* See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeExistingNode extends ILiquorTreeNode {
data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
// expand(): void;
}
/**
* Sent to liquor tree to define of new nodes.
* https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeNewNode extends ILiquorTreeNode {
text: string;
state: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
}
// https://amsik.github.io/liquor-tree/#Component-Options
export interface ILiquorTreeOptions {
multiple: boolean;
checkbox: boolean;
checkOnSelect: boolean;
autoCheckChildren: boolean;
parentSelect: boolean;
keyboardNavigation: boolean;
filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNode): boolean;
}
export interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
text: string;
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
export interface ILiquorTreeFilter {
emptyText: string;
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}
const LiquorTree: PluginObject<any> & VueClass<any>;
export default LiquorTree;
}

View File

@@ -0,0 +1,23 @@
import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeNode, ILiquorTreeExistingNode } from 'liquor-tree';
export class LiquorTreeOptions implements ILiquorTreeOptions {
public readonly multiple = true;
public readonly checkbox = true;
public readonly checkOnSelect = true;
/* For checkbox mode only. Children will have the same checked state as their parent.
⚠️ Setting this false, does not update indeterminate state of nodes.
This is false as it's handled manually to be able to batch select for performance + highlighting */
public readonly autoCheckChildren = false;
public readonly parentSelect = true;
public readonly keyboardNavigation = true;
public readonly filter = { // Wrap this in an arrow function as setting filter directly does not work JS APIs
emptyText: this.liquorTreeFilter.emptyText,
matcher: (query: string, node: ILiquorTreeExistingNode) => {
return this.liquorTreeFilter.matcher(query, node);
},
};
constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { }
public deletion(node: ILiquorTreeNode): boolean {
return false; // no op
}
}

View File

@@ -0,0 +1,17 @@
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
import { convertExistingToNode } from './NodeTranslator';
import { INode } from './../../Node/INode';
export type FilterPredicate = (node: INode) => boolean;
export class NodePredicateFilter implements ILiquorTreeFilter {
public emptyText = ''; // Does not matter as a custom mesage is shown
constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) {
throw new Error('filterPredicate is undefined');
}
}
public matcher(query: string, node: ILiquorTreeExistingNode): boolean {
return this.filterPredicate(convertExistingToNode(node));
}
}

View File

@@ -0,0 +1,61 @@
import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
import { NodeType } from './../../Node/INode';
export function getNewState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
const checked = getNewCheckedState(node, selectedNodeIds);
const indeterminate = !checked && getNewIndeterminateState(node, selectedNodeIds);
return {
checked, indeterminate,
};
}
function getNewIndeterminateState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): boolean {
switch (node.data.type) {
case NodeType.Script:
return false;
case NodeType.Category:
return parseAllSubScriptIds(node).some((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function getNewCheckedState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): boolean {
switch (node.data.type) {
case NodeType.Script:
return selectedNodeIds.some((id) => id === node.id);
case NodeType.Category:
return parseAllSubScriptIds(node).every((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray<string> {
if (categoryNode.data.type !== NodeType.Category) {
throw new Error('Not a category node');
}
if (!categoryNode.children) {
return [];
}
return categoryNode
.children
.flatMap((child) => getNodeIds(child));
}
function getNodeIds(node: ILiquorTreeNode): ReadonlyArray<string> {
switch (node.data.type) {
case NodeType.Script:
return [ node.id ];
case NodeType.Category:
return parseAllSubScriptIds(node);
default:
throw new Error('Unknown node type');
}
}

View File

@@ -0,0 +1,44 @@
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree';
import { INode } from './../../Node/INode';
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INode {
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
type: liquorTreeNode.data.type,
text: liquorTreeNode.data.text,
// selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: convertChildren(liquorTreeNode.children, convertExistingToNode),
documentationUrls: liquorTreeNode.data.documentationUrls,
isReversible : liquorTreeNode.data.isReversible,
};
}
export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode {
if (!node) { throw new Error('node is undefined'); }
return {
id: node.id,
text: node.text,
state: {
checked: false,
indeterminate: false,
},
children: convertChildren(node.children, toNewLiquorTreeNode),
data: {
documentationUrls: node.documentationUrls,
isReversible: node.isReversible,
type: node.type,
},
};
}
function convertChildren<TOldNode, TNewNode>(
oldChildren: readonly TOldNode[],
callback: (value: TOldNode) => TNewNode): TNewNode[] {
if (!oldChildren || oldChildren.length === 0) {
return [];
}
return oldChildren.map((childNode) => callback(childNode));
}

View File

@@ -0,0 +1,46 @@
<template>
<div class="documentationUrls">
<a v-for="url of this.documentationUrls"
v-bind:key="url"
:href="url"
:alt="url"
target="_blank" class="documentationUrl"
v-tooltip.top-center="url"
v-on:click.stop>
<font-awesome-icon :icon="['fas', 'info-circle']" />
</a>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component
export default class DocumentationUrls extends Vue {
@Prop() public documentationUrls: string[];
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
.documentationUrls {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.documentationUrl {
display: flex;
color: $gray;
cursor: pointer;
vertical-align: middle;
&:hover {
color: $slate;
}
&:not(:first-child) {
margin-left: 0.1em;
}
}
</style>

View File

@@ -0,0 +1,13 @@
export enum NodeType {
Script,
Category,
}
export interface INode {
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly documentationUrls: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>;
readonly type: NodeType;
}

View File

@@ -0,0 +1,48 @@
<template>
<div id="node">
<div class="item text">{{ this.data.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
<DocumentationUrls
class="item"
v-if="data.documentationUrls && data.documentationUrls.length > 0"
:documentationUrls="this.data.documentationUrls" />
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { INode } from './INode';
import RevertToggle from './RevertToggle.vue';
import DocumentationUrls from './DocumentationUrls.vue';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
RevertToggle,
DocumentationUrls,
},
})
export default class Node extends Vue {
@Prop() public data: INode;
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
#node {
display:flex;
flex-direction: row;
flex-wrap: wrap;
.text {
display: flex;
align-items: center;
}
.item:not(:first-child) {
margin-left: 5px;
}
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="checkbox-switch" >
<input type="checkbox" class="input-checkbox"
v-model="isReverted"
@change="onRevertToggledAsync()"
v-on:click.stop>
<div class="checkbox-animate">
<span class="checkbox-off">revert</span>
<span class="checkbox-on">revert</span>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { IReverter } from './Reverter/IReverter';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { INode } from './INode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { getReverter } from './Reverter/ReverterFactory';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
@Component
export default class RevertToggle extends StatefulVue {
@Prop() public node: INode;
public isReverted = false;
private handler: IReverter;
@Watch('node', {immediate: true}) public async onNodeChangedAsync(node: INode) {
const context = await this.getCurrentContextAsync();
this.handler = getReverter(node, context.state.collection);
}
public async onRevertToggledAsync() {
const context = await this.getCurrentContextAsync();
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.updateStatus(newState.selection.selectedScripts);
this.events.unsubscribeAll();
this.events.register(newState.selection.changed.on((scripts) => this.updateStatus(scripts)));
}
private updateStatus(scripts: ReadonlyArray<SelectedScript>) {
this.isReverted = this.handler.getState(scripts);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
$width: 85px;
$height: 30px;
// https://www.designlabthemes.com/css-toggle-switch/
.checkbox-switch {
cursor: pointer;
display: inline-block;
overflow: hidden;
position: relative;
width: $width;
height: $height;
-webkit-border-radius: $height;
border-radius: $height;
line-height: $height;
font-size: $height / 2;
display: inline-block;
input.input-checkbox {
position: absolute;
left: 0;
top: 0;
width: $width;
height: $height;
padding: 0;
margin: 0;
opacity: 0;
z-index: 2;
cursor: pointer;
}
.checkbox-animate {
position: relative;
width: $width;
height: $height;
background-color: $gray;
-webkit-transition: background-color 0.25s ease-out 0s;
transition: background-color 0.25s ease-out 0s;
// Circle
&:before {
$circle-size: $height * 0.66;
content: "";
display: block;
position: absolute;
width: $circle-size;
height: $circle-size;
border-radius: $circle-size * 2;
-webkit-border-radius: $circle-size * 2;
background-color: $slate;
top: $height * 0.16;
left: $width * 0.05;
-webkit-transition: left 0.3s ease-out 0s;
transition: left 0.3s ease-out 0s;
z-index: 10;
}
}
input.input-checkbox:checked {
+ .checkbox-animate {
background-color: $accent;
}
+ .checkbox-animate:before {
left: ($width - $width/3.5);
background-color: $light-gray;
}
+ .checkbox-animate .checkbox-off {
display: none;
opacity: 0;
}
+ .checkbox-animate .checkbox-on {
display: block;
opacity: 1;
}
}
.checkbox-off, .checkbox-on {
float: left;
color: $white;
font-weight: 700;
-webkit-transition: all 0.3s ease-out 0s;
transition: all 0.3s ease-out 0s;
}
.checkbox-off {
margin-left: $width / 3;
opacity: 1;
}
.checkbox-on {
display: none;
float: right;
margin-right: $width / 3;
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,30 @@
import { IReverter } from './IReverter';
import { getCategoryId } from '../../../ScriptNodeParser';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ScriptReverter } from './ScriptReverter';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export class CategoryReverter implements IReverter {
private readonly categoryId: number;
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
constructor(nodeId: string, collection: ICategoryCollection) {
this.categoryId = getCategoryId(nodeId);
this.scriptReverters = getAllSubScriptReverters(this.categoryId, collection);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
return this.scriptReverters.every((script) => script.getState(selectedScripts));
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateAllInCategory(this.categoryId, newState);
}
}
function getAllSubScriptReverters(categoryId: number, collection: ICategoryCollection) {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id "${categoryId}" does not exist`);
}
const scripts = category.getAllScriptsRecursively();
return scripts.map((script) => new ScriptReverter(script.id));
}

View File

@@ -0,0 +1,7 @@
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
export interface IReverter {
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;
selectWithRevertState(newState: boolean, selection: IUserSelection): void;
}

View File

@@ -0,0 +1,16 @@
import { INode, NodeType } from '../INode';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export function getReverter(node: INode, collection: ICategoryCollection): IReverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, collection);
case NodeType.Script:
return new ScriptReverter(node.id);
default:
throw new Error('Unknown script type');
}
}

View File

@@ -0,0 +1,21 @@
import { IReverter } from './IReverter';
import { getScriptId } from '../../../ScriptNodeParser';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
export class ScriptReverter implements IReverter {
private readonly scriptId: string;
constructor(nodeId: string) {
this.scriptId = getScriptId(nodeId);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
const selectedScript = selectedScripts.find((selected) => selected.id === this.scriptId);
if (!selectedScript) {
return false;
}
return selectedScript.revert;
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateSelectedScript(this.scriptId, newState);
}
}

View File

@@ -0,0 +1,137 @@
<template>
<span>
<span v-if="initialLiquourTreeNodes != null && initialLiquourTreeNodes.length > 0">
<tree :options="liquorTreeOptions"
:data="initialLiquourTreeNodes"
v-on:node:checked="nodeSelected($event)"
v-on:node:unchecked="nodeSelected($event)"
ref="treeElement"
>
<span class="tree-text" slot-scope="{ node }" >
<Node :data="convertExistingToNode(node)" />
</span>
</tree>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import LiquorTree from 'liquor-tree';
import Node from './Node/Node.vue';
import { INode } from './Node/INode';
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
import { INodeSelectedEvent } from './/INodeSelectedEvent';
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter';
import { ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
LiquorTree,
Node,
},
})
export default class SelectableTree extends Vue { // Keep it stateless to make it easier to switch out
@Prop() public filterPredicate?: FilterPredicate;
@Prop() public filterText?: string;
@Prop() public selectedNodeIds?: ReadonlyArray<string>;
@Prop() public initialNodes?: ReadonlyArray<INode>;
public initialLiquourTreeNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = new LiquorTreeOptions(new NodePredicateFilter((node) => this.filterPredicate(node)));
public convertExistingToNode = convertExistingToNode;
public nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
this.$emit('nodeSelected', event);
return;
}
@Watch('initialNodes', { immediate: true })
public async updateNodesAsync(nodes: readonly INode[]) {
if (!nodes) {
throw new Error('undefined initial nodes');
}
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) {
recurseDown(initialNodes,
(node) => node.state = updateState(node.state, node, this.selectedNodeIds));
}
this.initialLiquourTreeNodes = initialNodes;
const api = await this.getLiquorTreeApiAsync();
api.setModel(this.initialLiquourTreeNodes); // as liquor tree is not reactive to data after initialization
}
@Watch('filterText', { immediate: true })
public async updateFilterTextAsync(filterText: |string) {
const api = await this.getLiquorTreeApiAsync();
if (!filterText) {
api.clearFilter();
} else {
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
}
}
@Watch('selectedNodeIds')
public async setSelectedStatusAsync(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('SelectedrecurseDown nodes are undefined');
}
const tree = await this.getLiquorTreeApiAsync();
tree.recurseDown(
(node) => node.states = updateState(node.states, node, selectedNodeIds),
);
}
private async getLiquorTreeApiAsync(): Promise<ILiquorTree> {
const accessor = (): ILiquorTree => {
const uiElement = this.$refs.treeElement;
return uiElement ? (uiElement as any).tree : undefined;
};
const treeElement = await tryUntilDefinedAsync(accessor, 5, 20); // Wait for it to render
if (!treeElement) {
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
}
return treeElement;
}
}
function updateState(
old: ILiquorTreeNodeState,
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
return {...old, ...getNewState(node, selectedNodeIds)};
}
function recurseDown(
nodes: ReadonlyArray<ILiquorTreeNewNode>,
handler: (node: ILiquorTreeNewNode) => void) {
for (const node of nodes) {
handler(node);
if (node.children) {
recurseDown(node.children, handler);
}
}
}
async function tryUntilDefinedAsync<T>(
accessor: () => T | undefined,
delayInMs: number, maxTries: number): Promise<T | undefined> {
const sleepAsync = () => new Promise(((resolve) => setTimeout(resolve, delayInMs)));
let triesLeft = maxTries;
let value: T;
while (triesLeft !== 0) {
value = accessor();
if (value) {
return value;
}
triesLeft--;
await sleepAsync();
}
return value;
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div
class="handle"
:style="{ cursor: cursorCssValue }"
@mousedown="startResize">
<div class="line"></div>
<font-awesome-icon
class="image"
:icon="['fas', 'arrows-alt-h']"
/> <!-- exchange-alt arrows-alt-h-->
<div class="line"></div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class Handle extends Vue {
public readonly cursorCssValue = 'ew-resize';
private initialX: number = undefined;
public startResize(event: MouseEvent): void {
this.initialX = event.clientX;
document.body.style.setProperty('cursor', this.cursorCssValue);
document.addEventListener('mousemove', this.resize);
window.addEventListener('mouseup', this.stopResize);
event.stopPropagation();
event.preventDefault();
}
public resize(event: MouseEvent): void {
const displacementX = event.clientX - this.initialX;
this.$emit('resized', displacementX);
this.initialX = event.clientX;
}
public stopResize(): void {
document.body.style.removeProperty('cursor');
document.removeEventListener('mousemove', this.resize);
window.removeEventListener('mouseup', this.stopResize);
}
}
</script>
<style lang="scss" scoped>
@import "@/presentation/styles/colors.scss";
.handle {
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
&:hover {
.line {
background: $gray;
}
.image {
color: $gray;
}
}
.line {
flex: 1;
background: $dark-gray;
width: 3px;
}
.image {
color: $dark-gray;
}
margin-right: 10px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="slider">
<div class="left" ref="leftElement">
<slot name="left"></slot>
</div>
<Handle class="handle" @resized="onResize($event)" />
<div class="right">
<slot name="right"></slot>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import Handle from './Handle.vue';
@Component({
components: {
Handle,
},
})
export default class HorizontalResizeSlider extends Vue {
private get left(): HTMLElement { return this.$refs.leftElement as HTMLElement; }
public onResize(displacementX: number): void {
const leftWidth = this.left.offsetWidth + displacementX;
this.left.style.width = `${leftWidth}px`;
}
}
</script>
<style lang="scss" scoped>
@import "@/presentation/styles/media.scss";
.slider {
display: flex;
flex-direction: row;
.right {
flex: 1;
}
}
@media screen and (max-width: $vertical-view-breakpoint) {
.slider {
flex-direction: column;
.left {
width: auto !important;
}
.handle {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="scripts">
<TheScriptsMenu v-on:groupingChanged="grouping = $event" />
<HorizontalResizeSlider class="row">
<template v-slot:left>
<TheScriptsList :grouping="grouping" />
</template>
<template v-slot:right>
<TheCodeArea theme="xcode" />
</template>
</HorizontalResizeSlider>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import TheCodeArea from '@/presentation/components/Code/TheCodeArea.vue';
import TheScriptsList from '@/presentation/components/Scripts/TheScriptsList.vue';
import TheScriptsMenu from '@/presentation/components/Scripts/Menu/TheScriptsMenu.vue';
import HorizontalResizeSlider from '@/presentation/components/Scripts/Slider/HorizontalResizeSlider.vue';
import { Grouping } from '@/presentation/components/Scripts/Menu/Grouping/Grouping';
@Component({
components: {
TheCodeArea,
TheScriptsList,
TheScriptsMenu,
HorizontalResizeSlider,
},
})
export default class TheScriptArea extends Vue {
public grouping = Grouping.Cards;
}
</script>
<style scoped lang="scss">
.scripts {
> * + * {
margin-top: 15px;
}
}
::v-deep .left {
width: 55%; // initial width
min-width: 20%;
}
::v-deep .right {
min-width: 20%;
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<div class="scripts">
<div v-if="!isSearching">
<CardList v-if="grouping === Grouping.Cards"/>
<div class="tree" v-if="grouping === Grouping.None">
<ScriptsTree />
</div>
</div>
<div v-else> <!-- Searching -->
<div class="search">
<div class="search__query">
<div>Searching for "{{this.searchQuery | threeDotsTrim}}"</div>
<div class="search__query__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
v-on:click="clearSearchQueryAsync()"/>
</div>
</div>
<div v-if="!searchHasMatches" class="search-no-matches">
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim}}" 😞</div>
<div>Feel free to extend the scripts <a :href="repositoryUrl" target="_blank" class="child github" >here</a> </div>
</div>
</div>
<div v-if="searchHasMatches" class="tree tree--searching">
<ScriptsTree />
</div>
</div>
</div>
</template>
<script lang="ts">
import TheGrouper from '@/presentation/components/Scripts/Menu/Grouping/TheGrouper.vue';
import ScriptsTree from '@/presentation/components/Scripts/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/Cards/CardList.vue';
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { Grouping } from '@/presentation/components/Scripts/Menu/Grouping/Grouping';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ApplicationFactory } from '@/application/ApplicationFactory';
/** Shows content of single category or many categories */
@Component({
components: {
TheGrouper,
ScriptsTree,
CardList,
},
filters: {
threeDotsTrim(query: string) {
const threshold = 30;
if (query.length <= threshold - 3) {
return query;
}
return `${query.substr(0, threshold)}...`;
},
},
})
export default class TheScriptsList extends StatefulVue {
@Prop() public grouping: Grouping;
public repositoryUrl = '';
public Grouping = Grouping; // Make it accessible from the view
public searchQuery = '';
public isSearching = false;
public searchHasMatches = false;
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.repositoryUrl = app.info.repositoryWebUrl;
}
public async clearSearchQueryAsync() {
const context = await this.getCurrentContextAsync();
const filter = context.state.filter;
filter.removeFilter();
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.events.unsubscribeAll();
this.subscribeState(newState);
}
private subscribeState(state: ICategoryCollectionState) {
this.events.register(
state.filter.filterRemoved.on(() => {
this.isSearching = false;
}),
state.filter.filtered.on((result: IFilterResult) => {
this.searchQuery = result.query;
this.isSearching = true;
this.searchHasMatches = result.hasAnyMatches();
}),
);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
@import "@/presentation/styles/media.scss";
$inner-margin: 4px;
.scripts {
margin-top: $inner-margin;
@media screen and (min-width: $vertical-view-breakpoint) { // so the current code is always visible
overflow: auto;
max-height: 70vh;
}
.tree {
padding-left: 3%;
padding-top: 15px;
padding-bottom: 15px;
&--searching {
padding-top: 0px;
}
}
}
.search {
display: flex;
flex-direction: column;
background-color: $slate;
&__query {
display: flex;
justify-content: center;
flex-direction: row;
align-items: center;
margin-top: 1em;
color: $gray;
&__close-button {
cursor: pointer;
font-size: 1.25em;
margin-left: 0.25rem;
&:hover {
opacity: 0.9;
}
}
}
&-no-matches {
display:flex;
flex-direction: column;
word-break:break-word;
text-transform: uppercase;
color: $light-gray;
font-size: 1.5em;
padding:10px;
text-align:center;
> div {
padding-bottom:13px;
}
a {
color: $gray;
}
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div ref="containerElement" class="container">
<slot ref="containerElement"></slot>
</div>
</template>
<script lang="ts">
import { Component, Vue, Emit } from 'vue-property-decorator';
import ResizeObserver from 'resize-observer-polyfill';
import { throttle } from './Throttle';
@Component
export default class Responsive extends Vue {
private width: number;
private height: number;
private observer: ResizeObserver;
private get container(): HTMLElement { return this.$refs.containerElement as HTMLElement; }
public mounted() {
this.width = this.container.offsetWidth;
this.height = this.container.offsetHeight;
const resizeCallback = throttle(() => this.updateSize(), 200);
this.observer = new ResizeObserver(resizeCallback);
this.observer.observe(this.container);
this.fireChangeEvents();
}
public updateSize() {
let sizeChanged = false;
if (this.isWidthChanged()) {
this.updateWidth(this.container.offsetWidth);
sizeChanged = true;
}
if (this.isHeightChanged()) {
this.updateHeight(this.container.offsetHeight);
sizeChanged = true;
}
if (sizeChanged) {
this.$emit('sizeChanged');
}
}
@Emit('widthChanged') public updateWidth(width: number) {
this.width = width;
}
@Emit('heightChanged') public updateHeight(height: number) {
this.height = height;
}
public destroyed() {
if (this.observer) {
this.observer.disconnect();
}
}
private fireChangeEvents() {
this.updateWidth(this.container.offsetWidth);
this.updateHeight(this.container.offsetHeight);
this.$emit('sizeChanged');
}
private isWidthChanged(): boolean {
return this.width !== this.container.offsetWidth;
}
private isHeightChanged(): boolean {
return this.height !== this.container.offsetHeight;
}
}
</script>
<style scoped lang="scss">
.container {
width: 100%;
height: 100%;
display: inline-block; // if inline then it has no height or weight
}
</style>

View File

@@ -0,0 +1,37 @@
import { Component, Vue } from 'vue-property-decorator';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { buildContextAsync } from '@/application/Context/ApplicationContextFactory';
import { IApplicationContextChangedEvent } from '@/application/Context/IApplicationContext';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
// @ts-ignore because https://github.com/vuejs/vue-class-component/issues/91
@Component
export abstract class StatefulVue extends Vue {
private static readonly instance = new AsyncLazy<IApplicationContext>(() => buildContextAsync());
protected readonly events = new EventSubscriptionCollection();
private readonly ownEvents = new EventSubscriptionCollection();
public async mounted() {
const context = await this.getCurrentContextAsync();
this.ownEvents.register(context.contextChanged.on((event) => this.handleStateChangedEvent(event)));
this.handleCollectionState(context.state, undefined);
}
public destroyed() {
this.ownEvents.unsubscribeAll();
this.events.unsubscribeAll();
}
protected abstract handleCollectionState(
newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined): void;
protected getCurrentContextAsync(): Promise<IApplicationContext> {
return StatefulVue.instance.getValueAsync();
}
private handleStateChangedEvent(event: IApplicationContextChangedEvent) {
this.handleCollectionState(event.newState, event.oldState);
}
}

View File

@@ -0,0 +1,30 @@
export function throttle<T extends []>(
callback: (..._: T) => void, wait: number,
timer: ITimer = NodeTimer): (..._: T) => void {
let queuedToRun: ReturnType<typeof setTimeout>;
let previouslyRun: number;
return function invokeFn(...args: T) {
const now = timer.dateNow();
if (queuedToRun) {
queuedToRun = timer.clearTimeout(queuedToRun) as undefined;
}
if (!previouslyRun || (now - previouslyRun >= wait)) {
callback(...args);
previouslyRun = now;
} else {
queuedToRun = timer.setTimeout(invokeFn.bind(null, ...args), wait - (now - previouslyRun));
}
};
}
export interface ITimer {
setTimeout: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
clearTimeout: (timeoutId: ReturnType<typeof setTimeout>) => void;
dateNow(): number;
}
const NodeTimer: ITimer = {
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
dateNow: () => Date.now(),
};

View File

@@ -0,0 +1,72 @@
<template>
<span
class="container"
v-bind:class="{ 'container_unsupported': !hasCurrentOsDesktopVersion, 'container_supported': hasCurrentOsDesktopVersion }">
<span class="description">
<font-awesome-icon class="description__icon" :icon="['fas', 'desktop']" />
<span class="description__text">For desktop:</span>
</span>
<span class="urls">
<span class="urls__url" v-for="os of supportedDesktops" v-bind:key="os">
<DownloadUrlListItem :operatingSystem="os" />
</span>
</span>
</span>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import DownloadUrlListItem from './DownloadUrlListItem.vue';
@Component({
components: { DownloadUrlListItem },
})
export default class DownloadUrlList extends Vue {
public readonly supportedDesktops: ReadonlyArray<OperatingSystem>;
public readonly hasCurrentOsDesktopVersion: boolean = false;
constructor() {
super();
const supportedOperativeSystems = [OperatingSystem.Windows, OperatingSystem.Linux, OperatingSystem.macOS];
const currentOs = Environment.CurrentEnvironment.os;
this.supportedDesktops = supportedOperativeSystems.sort((os) => os === currentOs ? 0 : 1);
this.hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/media.scss";
.container {
display:flex;
flex-wrap: wrap;
justify-content: space-around;
&_unsupported {
opacity: 0.9;
}
&_supported {
font-size: 1em;
}
.description {
&__icon {
margin-right: 0.5em;
}
&__text {
margin-right: 0.3em;
}
}
}
.urls {
&__url {
&:not(:first-child)::before {
opacity: 0.5;
content: "|";
font-size: 0.6rem;
padding: 0 5px;
}
}
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<span class="url">
<a :href="downloadUrl"
v-bind:class="{
'url__active': hasCurrentOsDesktopVersion && isCurrentOs,
'url__inactive': hasCurrentOsDesktopVersion && !isCurrentOs,
}">{{ operatingSystemName }}</a>
</span>
</template>
<script lang="ts">
import { Component, Prop, Watch, Vue } from 'vue-property-decorator';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component
export default class DownloadUrlListItem extends Vue {
@Prop() public operatingSystem!: OperatingSystem;
public downloadUrl: string = '';
public operatingSystemName: string = '';
public isCurrentOs: boolean = false;
public hasCurrentOsDesktopVersion: boolean = false;
public async mounted() {
await this.onOperatingSystemChangedAsync(this.operatingSystem);
}
@Watch('operatingSystem')
public async onOperatingSystemChangedAsync(os: OperatingSystem) {
const currentOs = Environment.CurrentEnvironment.os;
this.isCurrentOs = os === currentOs;
this.downloadUrl = await this.getDownloadUrlAsync(os);
this.operatingSystemName = getOperatingSystemName(os);
this.hasCurrentOsDesktopVersion = hasDesktopVersion(currentOs);
}
private async getDownloadUrlAsync(os: OperatingSystem): Promise<string> {
const context = await ApplicationFactory.Current.getAppAsync();
return context.info.getDownloadUrl(os);
}
}
function hasDesktopVersion(os: OperatingSystem): boolean {
return os === OperatingSystem.Windows
|| os === OperatingSystem.Linux
|| os === OperatingSystem.macOS;
}
function getOperatingSystemName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Linux:
return 'Linux';
case OperatingSystem.macOS:
return 'macOS';
case OperatingSystem.Windows:
return 'Windows';
default:
throw new Error(`Unsupported os: ${OperatingSystem[os]}`);
}
}
</script>
<style scoped lang="scss">
.url {
&__active {
font-size: 1em;
}
&__inactive {
font-size: 0.70em;
}
a {
color:inherit;
&:hover {
opacity: 0.8;
}
}
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="privacy-policy">
<div v-if="!isDesktop" class="line">
<div class="line__emoji">🚫🍪</div>
<div>No cookies!</div>
</div>
<div v-if="isDesktop" class="line">
<div class="line__emoji">🚫🌐</div>
<div>Everything is offline, except single request GitHub to check for updates on application start.</div>
</div>
<div class="line">
<div class="line__emoji">🚫👀</div>
<div>No user behavior / IP adress collection!</div>
</div>
<div class="line">
<div class="line__emoji">🤖</div>
<div>All transparent: Deployed automatically from master branch
of the <a :href="repositoryUrl" target="_blank">source code</a> with no changes.</div>
</div>
<div v-if="!isDesktop" class="line">
<div class="line__emoji">📈</div>
<div>Basic <a href="https://aws.amazon.com/cloudfront/reporting/" target="_blank">CDN statistics</a>
are collected by AWS but they cannot be related to you or your behavior. You can download the offline version if you don't want CDN data collection.</div>
</div>
<div class="line">
<div class="line__emoji">🎉</div>
<div>As almost no data is colected, the application gets better only with your active feedback.
Feel free to <a :href="feedbackUrl" target="_blank">create an issue</a> 😊</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Environment } from '@/application/Environment/Environment';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { IApplication } from '@/domain/IApplication';
@Component
export default class PrivacyPolicy extends Vue {
public repositoryUrl: string = '';
public feedbackUrl: string = '';
public isDesktop = Environment.CurrentEnvironment.isDesktop;
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.initialize(app);
}
private initialize(app: IApplication) {
const info = app.info;
this.repositoryUrl = info.repositoryWebUrl;
this.feedbackUrl = info.feedbackUrl;
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/fonts.scss";
.privacy-policy {
display: flex;
flex-direction: column;
font-family: $normal-font;
text-align:center;
.line {
display: flex;
flex-direction: column;
&:not(:first-child) {
margin-top:0.2rem;
}
}
a {
color:inherit;
&:hover {
opacity: 0.8;
}
}
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<div>
<div class="footer">
<div class="footer__section">
<span v-if="isDesktop" class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'globe']" />
<span>Online version at <a :href="homepageUrl" target="_blank">{{ homepageUrl }}</a></span>
</span>
<span v-else class="footer__section__item">
<DownloadUrlList />
</span>
</div>
<div class="footer__section">
<div class="footer__section__item">
<a :href="feedbackUrl" target="_blank">
<font-awesome-icon class="icon" :icon="['far', 'smile']" />
<span>Feedback</span>
</a>
</div>
<div class="footer__section__item">
<a :href="repositoryUrl" target="_blank">
<font-awesome-icon class="icon" :icon="['fab', 'github']" />
<span>Source Code</span>
</a>
</div>
<div class="footer__section__item">
<a :href="releaseUrl" target="_blank">
<font-awesome-icon class="icon" :icon="['fas', 'tag']" />
<span>v{{ version }}</span>
</a>
</div>
<div class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'user-secret']" />
<a @click="$modal.show(modalName)">Privacy</a>
</div>
</div>
</div>
<modal :name="modalName" height="auto" :scrollable="true" :adaptive="true">
<div class="modal">
<PrivacyPolicy class="modal__content"/>
<div class="modal__close-button">
<font-awesome-icon :icon="['fas', 'times']" @click="$modal.hide(modalName)"/>
</div>
</div>
</modal>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Environment } from '@/application/Environment/Environment';
import PrivacyPolicy from './PrivacyPolicy.vue';
import DownloadUrlList from './DownloadUrlList.vue';
import { IApplication } from '@/domain/IApplication';
import { ApplicationFactory } from '@/application/ApplicationFactory';
@Component({
components: {
PrivacyPolicy, DownloadUrlList,
},
})
export default class TheFooter extends Vue {
public readonly modalName = 'privacy-policy';
public readonly isDesktop = Environment.CurrentEnvironment.isDesktop;
public version: string = '';
public repositoryUrl: string = '';
public releaseUrl: string = '';
public feedbackUrl: string = '';
public homepageUrl: string = '';
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.initialize(app);
}
private initialize(app: IApplication) {
const info = app.info;
this.version = info.version;
this.homepageUrl = info.homepage;
this.repositoryUrl = info.repositoryWebUrl;
this.releaseUrl = info.releaseUrl;
this.feedbackUrl = info.feedbackUrl;
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
@import "@/presentation/styles/media.scss";
.icon {
margin-right: 0.5em;
text-decoration: none;
}
.footer {
display: flex;
justify-content: space-between;
@media screen and (max-width: $big-screen-width) {
flex-direction: column;
align-items: center;
}
&__section {
display: flex;
@media screen and (max-width: $big-screen-width) {
justify-content: space-around;
width:100%;
&:not(:first-child) {
margin-top: 0.7em;
}
}
flex-wrap: wrap;
color: $dark-gray;
font-size: 1rem;
font-family: $normal-font;
a {
color:inherit;
text-decoration: underline;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
&__item:not(:first-child) {
&::before {
content: "|";
padding: 0 5px;
}
@media screen and (max-width: $big-screen-width) {
margin-top: 3px;
&::before {
content: "";
padding: 0;
}
}
}
}
}
.modal {
margin-bottom: 10px;
display: flex;
flex-direction: row;
&__content {
width: 100%;
}
&__close-button {
width: auto;
font-size: 1.5em;
margin-right:0.25em;
align-self: flex-start;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div id="container">
<h1 class="child title" >{{ title }}</h1>
<h2 class="child subtitle">Enforce privacy &amp; security on Windows and macOS</h2>
</div>
</template>
<script lang="ts">
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class TheHeader extends Vue {
public title = '';
public subtitle = '';
public async created() {
const app = await ApplicationFactory.Current.getAppAsync();
this.title = app.info.name;
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
#container {
display: flex;
align-items: center;
flex-direction: column;
.child {
display: flex;
text-align: center;
}
.title {
margin: 0;
color: $black;
text-transform: uppercase;
font-family: $main-font;
font-size: 2.5em;
line-height: 1.1;
}
.subtitle {
margin: 0;
font-size: 1.5em;
color: $gray;
font-family: $artistic-font;
font-weight: 500;
line-height: 1.2;
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="search" v-non-collapsing>
<input type="search" class="searchTerm"
:placeholder="searchPlaceHolder"
v-model="searchQuery" >
<div class="iconWrapper">
<font-awesome-icon :icon="['fas', 'search']" />
</div>
</div>
</template>
<script lang="ts">
import { Component, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { NonCollapsing } from '@/presentation/components/Scripts/Cards/NonCollapsingDirective';
import { IUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
@Component( {
directives: { NonCollapsing },
},
)
export default class TheSearchBar extends StatefulVue {
public searchPlaceHolder = 'Search';
public searchQuery = '';
@Watch('searchQuery')
public async updateFilterAsync(newFilter: |string) {
const context = await this.getCurrentContextAsync();
const filter = context.state.filter;
if (!newFilter) {
filter.removeFilter();
} else {
filter.setFilter(newFilter);
}
}
protected handleCollectionState(newState: ICategoryCollectionState, oldState: ICategoryCollectionState | undefined) {
const totalScripts = newState.collection.totalScripts;
this.searchPlaceHolder = `Search in ${totalScripts} scripts`;
this.searchQuery = newState.filter.currentFilter ? newState.filter.currentFilter.query : '';
this.events.unsubscribeAll();
this.subscribeFilter(newState.filter);
}
private subscribeFilter(filter: IUserFilter) {
this.events.register(filter.filtered.on((result) => this.handleFiltered(result)));
this.events.register(filter.filterRemoved.on(() => this.handleFilterRemoved()));
}
private handleFiltered(result: IFilterResult) {
this.searchQuery = result.query;
}
private handleFilterRemoved() {
this.searchQuery = '';
}
}
</script>
<style scoped lang="scss">
@import "@/presentation/styles/colors.scss";
@import "@/presentation/styles/fonts.scss";
.search {
width: 100%;
position: relative;
display: flex;
}
.searchTerm {
width: 100%;
min-width: 60px;
border: 1.5px solid $gray;
border-right: none;
height: 36px;
border-radius: 3px 0 0 3px;
padding-left:10px;
padding-right:10px;
outline: none;
color: $gray;
font-family: $normal-font;
font-size:1em;
}
.searchTerm:focus{
color: $slate;
}
.iconWrapper {
width: 40px;
height: 36px;
border: 1px solid $gray;
background: $gray;
text-align: center;
color: $white;
border-radius: 0 5px 5px 0;
font-size: 20px;
padding:5px;
}
</style>