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:
72
src/presentation/components/App.vue
Normal file
72
src/presentation/components/App.vue
Normal 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>
|
||||
55
src/presentation/components/Code/CodeButtons/Code.vue
Normal file
55
src/presentation/components/Code/CodeButtons/Code.vue
Normal 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>
|
||||
71
src/presentation/components/Code/CodeButtons/IconButton.vue
Normal file
71
src/presentation/components/Code/CodeButtons/IconButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
170
src/presentation/components/Code/CodeButtons/TheCodeButtons.vue
Normal file
170
src/presentation/components/Code/CodeButtons/TheCodeButtons.vue
Normal 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>
|
||||
161
src/presentation/components/Code/TheCodeArea.vue
Normal file
161
src/presentation/components/Code/TheCodeArea.vue
Normal 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>
|
||||
97
src/presentation/components/Scripts/Cards/CardList.vue
Normal file
97
src/presentation/components/Scripts/Cards/CardList.vue
Normal 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>
|
||||
267
src/presentation/components/Scripts/Cards/CardListItem.vue
Normal file
267
src/presentation/components/Scripts/Cards/CardListItem.vue
Normal 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>
|
||||
@@ -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, '');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum Grouping {
|
||||
Cards = 1,
|
||||
None = 0,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
82
src/presentation/components/Scripts/Menu/TheOsChanger.vue
Normal file
82
src/presentation/components/Scripts/Menu/TheOsChanger.vue
Normal 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>
|
||||
77
src/presentation/components/Scripts/Menu/TheScriptsMenu.vue
Normal file
77
src/presentation/components/Scripts/Menu/TheScriptsMenu.vue
Normal 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>
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
135
src/presentation/components/Scripts/ScriptsTree/ScriptsTree.vue
Normal file
135
src/presentation/components/Scripts/ScriptsTree/ScriptsTree.vue
Normal 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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { INode } from './Node/INode';
|
||||
|
||||
export interface INodeSelectedEvent {
|
||||
isSelected: boolean;
|
||||
node: INode;
|
||||
}
|
||||
76
src/presentation/components/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts
vendored
Normal file
76
src/presentation/components/Scripts/ScriptsTree/SelectableTree/LiquorTree/LiquorTree.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
71
src/presentation/components/Scripts/Slider/Handle.vue
Normal file
71
src/presentation/components/Scripts/Slider/Handle.vue
Normal 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>
|
||||
@@ -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>
|
||||
49
src/presentation/components/Scripts/TheScriptArea.vue
Normal file
49
src/presentation/components/Scripts/TheScriptArea.vue
Normal 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>
|
||||
159
src/presentation/components/Scripts/TheScriptsList.vue
Normal file
159
src/presentation/components/Scripts/TheScriptsList.vue
Normal 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>
|
||||
75
src/presentation/components/Shared/Responsive.vue
Normal file
75
src/presentation/components/Shared/Responsive.vue
Normal 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>
|
||||
|
||||
37
src/presentation/components/Shared/StatefulVue.ts
Normal file
37
src/presentation/components/Shared/StatefulVue.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/presentation/components/Shared/Throttle.ts
Normal file
30
src/presentation/components/Shared/Throttle.ts
Normal 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(),
|
||||
};
|
||||
72
src/presentation/components/TheFooter/DownloadUrlList.vue
Normal file
72
src/presentation/components/TheFooter/DownloadUrlList.vue
Normal 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>
|
||||
@@ -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>
|
||||
82
src/presentation/components/TheFooter/PrivacyPolicy.vue
Normal file
82
src/presentation/components/TheFooter/PrivacyPolicy.vue
Normal 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>
|
||||
162
src/presentation/components/TheFooter/TheFooter.vue
Normal file
162
src/presentation/components/TheFooter/TheFooter.vue
Normal 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>
|
||||
55
src/presentation/components/TheHeader.vue
Normal file
55
src/presentation/components/TheHeader.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div id="container">
|
||||
<h1 class="child title" >{{ title }}</h1>
|
||||
<h2 class="child subtitle">Enforce privacy & 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>
|
||||
100
src/presentation/components/TheSearchBar.vue
Normal file
100
src/presentation/components/TheSearchBar.vue
Normal 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>
|
||||
Reference in New Issue
Block a user