Merge pull request #55 from NBTX/master

🎨 Add theme support (and settings storage)
This commit is contained in:
Liyas Thomas
2019-08-25 07:27:33 +05:30
committed by GitHub
15 changed files with 860 additions and 426 deletions

View File

@@ -1,13 +1,8 @@
:root {
--bg-color: #121212;
--fg-color: #fff;
--ac-color: #51ff0d;
--err-color: #393939;
}
$responsiveWidth: 720px;
::selection {
background-color: var(--ac-color);
color: var(--bg-color);
color: var(--act-color);
}
::-webkit-scrollbar {
@@ -70,6 +65,20 @@ footer {
justify-content: space-between;
}
@media(max-width: $responsiveWidth){
header {
display: block;
text-align: center;
nav {
display: inline-flex;
margin-top: 20px;
}
}
}
nav {
a:not(:last-of-type) {
margin-right: 15px;
@@ -92,7 +101,7 @@ button {
padding: 8px 16px;
border-radius: 4px;
background-color: var(--ac-color);
color: var(--bg-color);
color: var(--act-color);
font-weight: 700;
font-size: 16px;
cursor: pointer;
@@ -115,55 +124,55 @@ fieldset pre {
resize: vertical;
}
fieldset.request {
fieldset.blue {
border-color: #57b5f9;
}
fieldset.request legend {
fieldset.blue legend {
color: #57b5f9;
}
fieldset.history {
border-color: #9b9b9b;
fieldset.gray {
border-color: #9B9B9B;
}
fieldset.history legend {
color: #9b9b9b;
fieldset.gray legend {
color: #9B9B9B;
}
fieldset.authentication {
border-color: #b8e986;
fieldset.green {
border-color: #B8E986;
}
fieldset.authentication legend {
color: #b8e986;
fieldset.green legend {
color: #B8E986;
}
fieldset.parameters {
border-color: #50e3c2;
fieldset.cyan {
border-color: #50E3C2;
}
fieldset.parameters legend {
color: #50e3c2;
fieldset.cyan legend {
color: #50E3C2;
}
fieldset.reqbody {
border-color: #4a90e2;
fieldset.blue-dark {
border-color: #4A90E2;
}
fieldset.reqbody legend {
color: #4a90e2;
fieldset.blue-dark legend {
color: #4A90E2;
}
fieldset.response {
border-color: #c198fb;
fieldset.purple {
border-color: #C198FB;
}
fieldset.response legend {
color: #c198fb;
fieldset.purple legend {
color: #C198FB;
}
.hidden .collapsible {
.collapsible.hidden {
display: none;
}
@@ -176,13 +185,28 @@ pre {
padding: 8px 16px;
width: calc(100% - 8px);
border-radius: 4px;
background-color: #000;
background-color: var(--bg-dark-color);
color: var(--fg-color);
font-weight: 700;
font-size: 18px;
font-family: monospace;
}
// Force the same height, for dropdowns and regular input boxes.
select,
input,
option {
height: 38px;
}
input[type="checkbox"] {
width: initial;
&, & + label {
vertical-align: middle;
}
}
.error {
background-color: var(--err-color);
}
@@ -211,7 +235,7 @@ ol li {
flex-grow: 1;
}
@media (max-width: 720px) {
@media (max-width: $responsiveWidth) {
ul,
ol {
flex-direction: column;

39
assets/css/themes.scss Normal file
View File

@@ -0,0 +1,39 @@
/**
Main Themes:
- dark (default)
- light
*/
// Dark is the default theme variant.
:root {
--bg-dark-color: #000000;
// Background color
--bg-color: #121212;
// Text color
--fg-color: #FFF;
// Error color
--err-color: #393939;
// Active color
--ac-color: #51FF0D;
// Active text color
--act-color: #121212;
}
:root.light {
--bg-dark-color: #ffffff;
// Background color
--bg-color: #F6F8FA;
// Text color
--fg-color: #121212;
// Error color
--err-color: invert(#393939, 1);
// Active color
--ac-color: #51FF0D;
// Active text color
--act-color: #121212;
}

29
components/logo.vue Normal file
View File

@@ -0,0 +1,29 @@
<template>
<svg version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 612.001 612.001" style="enable-background:new 0 0 612.001 612.001;" xml:space="preserve">
<defs id="defs11" />
<g id="g3826" transform="translate(-516.40798,-163.88978)">
<circle transform="scale(1,-1)" style="stroke-width:1.19531453" r="178.70923" cy="-501.55591" cx="822.40845" id="circle3814" />
<g id="g3820" transform="translate(516.40798,163.89028)">
<g id="g3818">
<path :fill="color" id="path3816" data-old_color="#121212" class="active-path" data-original="#121212" d="M 64.601,236.822 C 64.601,394.256 192.786,612 306.001,612 412.582,612 547.4,394.256 547.4,236.822 547.4,79.388 439.322,0 306,0 172.678,0 64.601,79.388 64.601,236.822 Z m 304.12,116.415 c 29.475,-29.475 70.598,-40.195 108.552,-32.173 8.021,37.954 -2.698,79.077 -32.173,108.552 -29.475,29.475 -70.598,40.195 -108.552,32.173 -8.022,-37.955 2.698,-79.078 32.173,-108.552 z M 134.727,321.063 c 37.954,-8.021 79.077,2.698 108.552,32.173 29.475,29.475 40.195,70.598 32.173,108.552 -37.954,8.021 -79.077,-2.698 -108.552,-32.173 -29.475,-29.476 -40.194,-70.598 -32.173,-108.552 z" />
</g>
</g>
</g>
</svg>
</template>
<style>
#path3816 {
fill: var(--ac-color);
}
</style>
<script>
export default {
props: {
'color': {
type: String
}
}
}
</script>

47
components/section.vue Normal file
View File

@@ -0,0 +1,47 @@
<template>
<fieldset :class="{ 'no-colored-frames': noFrameColors }">
<legend @click.prevent="collapse">{{ label }} </legend>
<div class="collapsible" :class="{ hidden: collapsed }">
<slot />
</div>
</fieldset>
</template>
<style>
fieldset.no-colored-frames {
border-color: #afafaf !important;
}
fieldset.no-colored-frames legend {
color: var(--ac-color);
}
</style>
<script>
export default {
computed: {
noFrameColors () {
return this.$store.state.postwoman.settings.DISABLE_FRAME_COLORS || false;
}
},
props: {
"label": {
type: String,
default: "Section"
},
"collapsed": {
type: Boolean
}
},
methods: {
collapse({ target }) {
const parent = target.parentNode;
parent.querySelector(".collapsible").classList.toggle('hidden');
},
}
}
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="color" :data-color="color">
<span :style="{backgroundColor: color}" class="preview">
<svg v-if="active" class="activeTick" width="24" height="24" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd"><path d="M21 6.285l-11.16 12.733-6.84-6.018 1.319-1.49 5.341 4.686 9.865-11.196 1.475 1.285z"/></svg>
</span>
{{ name || color }}
</div>
</template>
<style lang="scss">
.color {
display: inline-block;
vertical-align: middle;
padding: 8px 15px;
margin: 5px;
background-color: rgba(93, 93, 93, 0.2);
border-radius: 4px;
&.active {
background-color: rgba(93, 93, 93, 0.3);
}
.preview {
vertical-align: middle;
display: inline-block;
width: 36px;
height: 36px;
border-radius: 100%;
margin-right: 10px;
position: relative;
.activeTick {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
fill: white;
}
}
}
.color.vibrant {
.preview .activeTick {
fill: black;
}
}
</style>
<script>
export default {
props: {
'color': {
type: String,
required: true
},
'name': {
type: String
},
'active': {
type: Boolean,
default: false
}
}
}
</script>

View File

@@ -3,7 +3,7 @@
<header>
<div class="slide-in">
<nuxt-link to="/">
<h1 class="logo"><img src="~static/icons/logo.svg" alt="" style="height: 24px; margin-right: 16px">Postwoman</h1>
<h1 class="logo"><logo alt="" style="height: 24px; margin-right: 16px"/>Postwoman</h1>
</nuxt-link>
<h3>Lightweight API request builder</h3>
</div>
@@ -11,13 +11,22 @@
<nav>
<nuxt-link to="/">HTTP</nuxt-link>
<nuxt-link to="/websocket">WebSocket</nuxt-link>
<nuxt-link to="/settings">
<!-- Settings cog -->
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<path d="M24 14.187v-4.374c-2.148-.766-2.726-.802-3.027-1.529-.303-.729.083-1.169 1.059-3.223l-3.093-3.093c-2.026.963-2.488 1.364-3.224 1.059-.727-.302-.768-.889-1.527-3.027h-4.375c-.764 2.144-.8 2.725-1.529 3.027-.752.313-1.203-.1-3.223-1.059l-3.093 3.093c.977 2.055 1.362 2.493 1.059 3.224-.302.727-.881.764-3.027 1.528v4.375c2.139.76 2.725.8 3.027 1.528.304.734-.081 1.167-1.059 3.223l3.093 3.093c1.999-.95 2.47-1.373 3.223-1.059.728.302.764.88 1.529 3.027h4.374c.758-2.131.799-2.723 1.537-3.031.745-.308 1.186.099 3.215 1.062l3.093-3.093c-.975-2.05-1.362-2.492-1.059-3.223.3-.726.88-.763 3.027-1.528zm-4.875.764c-.577 1.394-.068 2.458.488 3.578l-1.084 1.084c-1.093-.543-2.161-1.076-3.573-.49-1.396.581-1.79 1.693-2.188 2.877h-1.534c-.398-1.185-.791-2.297-2.183-2.875-1.419-.588-2.507-.045-3.579.488l-1.083-1.084c.557-1.118 1.066-2.18.487-3.58-.579-1.391-1.691-1.784-2.876-2.182v-1.533c1.185-.398 2.297-.791 2.875-2.184.578-1.394.068-2.459-.488-3.579l1.084-1.084c1.082.538 2.162 1.077 3.58.488 1.392-.577 1.785-1.69 2.183-2.875h1.534c.398 1.185.792 2.297 2.184 2.875 1.419.588 2.506.045 3.579-.488l1.084 1.084c-.556 1.121-1.065 2.187-.488 3.58.577 1.391 1.689 1.784 2.875 2.183v1.534c-1.188.398-2.302.791-2.877 2.183zm-7.125-5.951c1.654 0 3 1.346 3 3s-1.346 3-3 3-3-1.346-3-3 1.346-3 3-3zm0-2c-2.762 0-5 2.238-5 5s2.238 5 5 5 5-2.238 5-5-2.238-5-5-5z"/>
</svg>
</nuxt-link>
</nav>
</header>
<nuxt id="main" />
<footer>
<a href="https://github.com/liyasthomas/postwoman" target="_blank"><img src="~static/icons/github.svg" alt="" style="margin-right: 16px">GitHub</a>
<div>
<a href="https://github.com/liyasthomas/postwoman" target="_blank"><img src="~static/icons/github.svg" alt="" style="margin-right: 16px">GitHub</a>
</div>
<button id="installPWA" @click.prevent="showInstallPrompt()">
Install PWA
</button>
@@ -47,16 +56,21 @@
max-width: 1200px;
}
header { padding-right: 0; }
nav {
svg {
vertical-align: sub;
}
a {
display: inline-block;
position: relative;
padding: 8px 16px;
fill: var(--fg-color);
color: var(--fg-color);
&.nuxt-link-exact-active {
color: black;
color: var(--act-color);
fill: var(--act-color);
&:before { width: 100%; height: 100% }
}
@@ -87,8 +101,13 @@
<script>
import intializePwa from '../assets/js/pwa';
import logo from "../components/logo";
export default {
components: {
logo
},
data () {
return {
// Once the PWA code is initialized, this holds a method
@@ -98,6 +117,22 @@
}
},
beforeMount () {
// Load theme settings
(() => {
// Apply theme from settings.
document.documentElement.className = this.$store.state.postwoman.settings.THEME_CLASS || '';
// Load theme color data from settings, or use default color.
let color = this.$store.state.postwoman.settings.THEME_COLOR || '#51FF0D';
let vibrant = this.$store.state.postwoman.settings.THEME_COLOR_VIBRANT;
if(vibrant == null) vibrant = true;
document.documentElement.style.setProperty('--ac-color', color);
document.documentElement.style.setProperty('--act-color', vibrant ? '#121212' : '#fff');
})();
},
mounted () {
// Initializes the PWA code - checks if the app is installed,
// etc.

View File

@@ -86,10 +86,12 @@ export default {
** Customize the progress-bar color
*/
loading: { color: '#88FB4F' },
/*
** Global CSS
*/
css: [
'@/assets/css/themes.scss',
'@/assets/css/fonts.scss',
'@/assets/css/styles.scss'
],
@@ -97,6 +99,7 @@ export default {
** Plugins to load before mounting the App
*/
plugins: [
{ src: '~/plugins/vuex-persist' }
],
/*
** Nuxt.js dev-modules

19
package-lock.json generated
View File

@@ -4410,6 +4410,11 @@
"locate-path": "^3.0.0"
}
},
"flatted": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.1.tgz",
"integrity": "sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg=="
},
"flatten": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.2.tgz",
@@ -5731,6 +5736,11 @@
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4="
},
"lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"lodash.template": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz",
@@ -9992,6 +10002,15 @@
"resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz",
"integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg=="
},
"vuex-persist": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/vuex-persist/-/vuex-persist-2.0.1.tgz",
"integrity": "sha512-V3WSBYmxcAP6ei0VVt2lxGloeWJZgk1Ao4r+iOxLMhAv+UYIK91qbjwvqk6Xr3tAi052jzSwn8aoamtuz8ArsQ==",
"requires": {
"flatted": "^2.0.0",
"lodash.merge": "^4.6.1"
}
},
"watchpack": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.0.tgz",

View File

@@ -11,8 +11,9 @@
"generate": "nuxt generate"
},
"dependencies": {
"@nuxtjs/pwa": "^3.0.0-0",
"nuxt": "^2.0.0",
"@nuxtjs/pwa": "^3.0.0-0"
"vuex-persist": "^2.0.1"
},
"devDependencies": {
"node-sass": "^4.12.0",

View File

@@ -1,196 +1,183 @@
<template>
<div class="page">
<fieldset class="request" ref="request">
<legend v-on:click="collapse">Request </legend>
<div class="collapsible">
<ul>
<li>
<label for="method">Method</label>
<select v-model="method">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>DELETE</option>
<option>OPTIONS</option>
</select>
</li>
<li>
<label for="url">URL</label>
<input type="url" v-bind:class="{ error: urlNotValid }" v-model="url" v-on:keyup.enter="sendRequest">
</li>
<li>
<label for="path">Path</label>
<input v-model="path" v-on:keyup.enter="sendRequest">
</li>
<li>
<label for="action">&nbsp;</label>
<button v-bind:class="{ disabled: urlNotValid }" name="action" @click="sendRequest">Send</button>
</li>
</ul>
</div>
</fieldset>
<fieldset class="reqbody" v-if="method === 'POST' || method === 'PUT'">
<legend v-on:click="collapse">Request Body </legend>
<div class="collapsible">
<ul>
<li>
<label>Content Type</label>
<select v-model="contentType">
<option>application/json</option>
<option>www-form/urlencoded</option>
</select>
</li>
</ul>
<ol v-for="(param, index) in bodyParams">
<li>
<label :for="'bparam'+index">Key {{index + 1}}</label>
<input :name="'bparam'+index" v-model="param.key">
</li>
<li>
<label :for="'bvalue'+index">Value {{index + 1}}</label>
<input :name="'bvalue'+index" v-model="param.value">
</li>
<li>
<label for="request">&nbsp;</label>
<button name="request" @click="removeRequestBodyParam(index)">Remove</button>
</li>
</ol>
<ul>
<li>
<label for="addrequest">Action</label>
<button name="addrequest" @click="addRequestBodyParam">Add</button>
</li>
</ul>
<ul>
<li>
<label for="request">Parameter List</label>
<textarea name="request" rows="1" readonly>{{rawRequestBody || '(add at least one parameter)'}}</textarea>
</li>
</ul>
</div>
</fieldset>
<fieldset class="authentication hidden">
<legend v-on:click="collapse">Authentication </legend>
<div class="collapsible">
<ul>
<li>
<label for="auth">Authentication Type</label>
<select v-model="auth">
<option>None</option>
<option>Basic</option>
<option>Bearer Token</option>
</select>
</li>
</ul>
<ul v-if="auth === 'Basic'">
<li>
<label for="http_basic_user">User</label>
<input v-model="httpUser">
</li>
<li>
<label for="http_basic_passwd">Password</label>
<input v-model="httpPassword" type="password">
</li>
</ul>
<ul v-if="auth === 'Bearer Token'">
<li>
<label for="bearer_token">Token</label>
<input v-model="bearerToken">
</li>
</ul>
</div>
</fieldset>
<fieldset class="parameters hidden">
<legend v-on:click="collapse">Parameters </legend>
<div class="collapsible">
<ol v-for="(param, index) in params">
<li>
<label :for="'param'+index">Key {{index + 1}}</label>
<input :name="'param'+index" v-model="param.key">
</li>
<li>
<label :for="'value'+index">Value {{index + 1}}</label>
<input :name="'value'+index" v-model="param.value">
</li>
<li>
<label for="param">&nbsp;</label>
<button name="param" @click="removeRequestParam(index)">Remove</button>
</li>
</ol>
<ul>
<li>
<label for="add">Action</label>
<button name="add" @click="addRequestParam">Add</button>
</li>
</ul>
<ul>
<li>
<label for="request">Parameter List</label>
<textarea name="request" rows="1" readonly>{{queryString || '(add at least one parameter)'}}</textarea>
</li>
</ul>
</div>
</fieldset>
<fieldset class="response" id="response" ref="response">
<legend v-on:click="collapse">Response </legend>
<div class="collapsible">
<ul>
<li>
<label for="status">status</label>
<input name="status" type="text" readonly :value="response.status || '(waiting to send request)'" :class="statusCategory ? statusCategory.className : ''" >
</li>
</ul>
<ul v-for="(value, key) in response.headers">
<li>
<label for="value">{{key}}</label>
<input name="value" :value="value" readonly>
</li>
</ul>
<ul>
<li>
<label for="body">response</label>
<textarea name="body" rows="10" readonly>{{response.body || '(waiting to send request)'}}</textarea>
</li>
</ul>
</div>
</fieldset>
<fieldset class="history">
<legend v-on:click="collapse">History </legend>
<div class="collapsible">
<ul>
<li>
<button v-bind:class="{ disabled: noHistoryToClear }" v-on:click="clearHistory">Clear History</button>
</li>
</ul>
<ul v-for="entry in history">
<li>
<label for="time">Time</label>
<input name="time" type="text" readonly :value="entry.time">
</li>
<li>
<label for="name">Method</label>
<input name="name" type="text" readonly :value="entry.method">
</li>
<li>
<label for="name">URL</label>
<input name="name" type="text" readonly :value="entry.url">
</li>
<li>
<label for="name">Path</label>
<input name="name" type="text" readonly :value="entry.path">
</li>
<li>
<label for="delete">&nbsp;</label>
<button name="delete" @click="deleteHistory(entry)">Delete</button>
</li>
<li>
<label for="use">&nbsp;</label>
<button name="use" @click="useHistory(entry)">Use</button>
</li>
</ul>
</div>
</fieldset>
<pw-section class="blue" label="Request" ref="request">
<ul>
<li>
<label for="method">Method</label>
<select id="method" v-model="method">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>DELETE</option>
<option>OPTIONS</option>
</select>
</li>
<li>
<label for="url">URL</label>
<input id="url" type="url" v-bind:class="{ error: !isValidURL }" v-model="url" v-on:keyup.enter="sendRequest">
</li>
<li>
<label for="path">Path</label>
<input id="path" v-model="path" v-on:keyup.enter="sendRequest">
</li>
<li>
<label for="action">&nbsp;</label>
<button id="action" name="action" @click="sendRequest" :disabled="!isValidURL">Send</button>
</li>
</ul>
</pw-section>
<pw-section class="blue-dark" label="Request Body" v-if="method === 'POST' || method === 'PUT'">
<ul>
<li>
<label>Content Type</label>
<select v-model="contentType">
<option>application/json</option>
<option>www-form/urlencoded</option>
</select>
</li>
</ul>
<ol v-for="(param, index) in bodyParams">
<li>
<label :for="'bparam'+index">Key {{index + 1}}</label>
<input :name="'bparam'+index" v-model="param.key">
</li>
<li>
<label :for="'bvalue'+index">Value {{index + 1}}</label>
<input :name="'bvalue'+index" v-model="param.value">
</li>
<li>
<label for="request">&nbsp;</label>
<button name="request" @click="removeRequestBodyParam(index)">Remove</button>
</li>
</ol>
<ul>
<li>
<label for="addrequest">Action</label>
<button name="addrequest" @click="addRequestBodyParam">Add</button>
</li>
</ul>
<ul>
<li>
<label for="request">Parameter List</label>
<textarea name="request" rows="1" readonly>{{rawRequestBody || '(add at least one parameter)'}}</textarea>
</li>
</ul>
</pw-section>
<pw-section class="green" label="Authentication" collapsed>
<ul>
<li>
<label for="auth">Authentication Type</label>
<select v-model="auth">
<option>None</option>
<option>Basic</option>
<option>Bearer Token</option>
</select>
</li>
</ul>
<ul v-if="auth === 'Basic'">
<li>
<label for="http_basic_user">User</label>
<input v-model="httpUser">
</li>
<li>
<label for="http_basic_passwd">Password</label>
<input v-model="httpPassword" type="password">
</li>
</ul>
<ul v-if="auth === 'Bearer Token'">
<li>
<label for="bearer_token">Token</label>
<input v-model="bearerToken">
</li>
</ul>
</pw-section>
<pw-section class="cyan" label="Parameters" collapsed>
<ol v-for="(param, index) in params">
<li>
<label :for="'param'+index">Key {{index + 1}}</label>
<input :name="'param'+index" v-model="param.key">
</li>
<li>
<label :for="'value'+index">Value {{index + 1}}</label>
<input :name="'value'+index" v-model="param.value">
</li>
<li>
<label for="param">&nbsp;</label>
<button name="param" @click="removeRequestParam(index)">Remove</button>
</li>
</ol>
<ul>
<li>
<label for="add">Action</label>
<button name="add" @click="addRequestParam">Add</button>
</li>
</ul>
<ul>
<li>
<label for="request">Parameter List</label>
<textarea name="request" rows="1" readonly>{{queryString || '(add at least one parameter)'}}</textarea>
</li>
</ul>
</pw-section>
<pw-section class="purple" label="Response" id="response" ref="response">
<ul>
<li>
<label for="status">status</label>
<input name="status" type="text" readonly :value="response.status || '(waiting to send request)'" :class="statusCategory ? statusCategory.className : ''">
</li>
</ul>
<ul v-for="(value, key) in response.headers">
<li>
<label for="value">{{key}}</label>
<input name="value" :value="value" readonly>
</li>
</ul>
<ul>
<li>
<label for="body">response</label>
<textarea name="body" rows="10" readonly>{{response.body || '(waiting to send request)'}}</textarea>
</li>
</ul>
</pw-section>
<pw-section class="gray" label="History">
<ul>
<li>
<button v-bind:class="{ disabled: noHistoryToClear }" v-on:click="clearHistory">Clear History</button>
</li>
</ul>
<ul v-for="entry in history">
<li>
<label for="time">Time</label>
<input name="time" type="text" readonly :value="entry.time">
</li>
<li>
<label for="name">Method</label>
<input name="name" type="text" readonly :value="entry.method">
</li>
<li>
<label for="name">URL</label>
<input name="name" type="text" readonly :value="entry.url">
</li>
<li>
<label for="name">Path</label>
<input name="name" type="text" readonly :value="entry.path">
</li>
<li>
<label for="delete">&nbsp;</label>
<button name="delete" @click="deleteHistory(entry)">Delete</button>
</li>
<li>
<label for="use">&nbsp;</label>
<button name="use" @click="useHistory(entry)">Use</button>
</li>
</ul>
</pw-section>
</div>
</template>
@@ -208,7 +195,13 @@
return headerMap
};
import section from "../components/section";
export default {
components: {
'pw-section': section
},
data () {
return {
method: 'GET',
@@ -231,27 +224,24 @@
},
computed: {
statusCategory(){
const statusCategory = [
return [
{name: 'informational', statusCodeRegex: new RegExp(/[1][0-9]+/), className: 'info-response'},
{name: 'successful', statusCodeRegex: new RegExp(/[2][0-9]+/), className: 'success-response'},
{name: 'redirection', statusCodeRegex: new RegExp(/[3][0-9]+/), className: 'redir-response'},
{name: 'client error', statusCodeRegex: new RegExp(/[4][0-9]+/), className: 'cl-error-response'},
{name: 'server error', statusCodeRegex: new RegExp(/[5][0-9]+/), className: 'sv-error-response'},
].find(status => status.statusCodeRegex.test(this.response.status));
return statusCategory;
},
noHistoryToClear() {
return this.history.length === 0;
},
urlNotValid() {
const pattern = new RegExp('^(https?:\\/\\/)?' +
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' +
'((\\d{1,3}\\.){3}\\d{1,3}))' +
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' +
'(\\?[;&a-z\\d%_.~+=-]*)?' +
'(\\#[-a-z\\d_]*)?$', 'i');
return !pattern.test(this.url)
isValidURL() {
const protocol = '^(https?:\\/\\/)?';
const validIP = new RegExp(protocol + "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$");
const validHostname = new RegExp(protocol + "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$");
return validIP.test(this.url) || validHostname.test(this.url);
},
rawRequestBody() {
const {
@@ -306,19 +296,13 @@
this.method = method
this.url = url
this.path = path
this.$refs.request.scrollIntoView({
this.$refs.request.$el.scrollIntoView({
behavior: 'smooth'
})
},
collapse({
target
}) {
const el = target.parentNode.className
document.getElementsByClassName(el)[0].classList.toggle('hidden')
},
sendRequest() {
if (this.urlNotValid) {
alert('Please check the formatting of the URL')
if (!this.isValidURL()) {
alert('Please check the formatting of the URL');
return
}
const n = new Date().toLocaleTimeString()
@@ -329,10 +313,10 @@
path: this.path
}, ...this.history]
window.localStorage.setItem('history', JSON.stringify(this.history))
if (this.$refs.response.classList.contains('hidden')) {
this.$refs.response.classList.toggle('hidden')
if (this.$refs.response.$el.classList.contains('hidden')) {
this.$refs.response.$el.classList.toggle('hidden')
}
this.$refs.response.scrollIntoView({
this.$refs.response.$el.scrollIntoView({
behavior: 'smooth'
})
this.response.status = 'Fetching...'

126
pages/settings.vue Normal file
View File

@@ -0,0 +1,126 @@
<template>
<div class="page">
<pw-section class="blue" label="Theme">
<ul>
<li>
<h3>Background</h3>
<div class="backgrounds">
<span v-for="theme in themes" :key="theme.class" @click="applyTheme(theme.class)">
<swatch :color="theme.color" :name="theme.name" :class="{ vibrant: theme.vibrant }" :active="settings.THEME_CLASS === theme.class"></swatch>
</span>
</div>
</li>
</ul>
<br><br>
<ul>
<li>
<h3>Color</h3>
<div class="colors">
<span v-for="entry in colors" :key="entry.color"
@click.prevent="setActiveColor(entry.color, entry.vibrant)">
<swatch
:color="entry.color"
:name="entry.name"
:class="{ vibrant: entry.vibrant }"
:active="settings.THEME_COLOR === entry.color.toUpperCase()" />
</span>
</div>
<p>
<input id="disableFrameColors" type="checkbox"
:checked="!settings.DISABLE_FRAME_COLORS"
@change="toggleSetting('DISABLE_FRAME_COLORS')">
<label for="disableFrameColors">Enable multi-colored frames</label>
</p>
</li>
</ul>
</pw-section>
</div>
</template>
<script>
import section from "../components/section";
import swatch from "../components/settings/swatch";
export default {
data () {
return {
// NOTE:: You need to first set the CSS for your theme in /assets/css/themes.scss
// You should copy the existing light theme as a template and then just
// set the relevant values.
themes: [
{ "color": "#121212", "name": "Dark (Default)", "class": "" },
{ "color": "#DFDFDF", "name": "Light", "vibrant": true, "class": "light" }
],
// You can define a new color here! It will simply store the color value.
colors: [
// If the color is vibrant, black is used as the active foreground color.
{ "color": "#51ff0d", "name":"Lime (Default)", "vibrant": true },
{ "color": "#FFC107", "name":"Yellow", "vibrant": true },
{ "color": "#E91E63", "name":"Pink", "vibrant": false },
{ "color": "#e74c3c", "name":"Red", "vibrant": false },
{ "color": "#9b59b6", "name":"Purple", "vibrant": false },
{ "color": "#2980b9", "name":"Blue", "vibrant": false },
],
settings: {
THEME_CLASS: this.$store.state.postwoman.settings.THEME_CLASS || '',
THEME_COLOR: '',
THEME_COLOR_VIBRANT: true,
DISABLE_FRAME_COLORS: this.$store.state.postwoman.settings.DISABLE_FRAME_COLORS || false
}
}
},
components: {
'pw-section': section,
'swatch': swatch
},
methods: {
applyTheme (name) {
this.applySetting('THEME_CLASS', name);
document.documentElement.className = name;
},
setActiveColor (color, vibrant) {
// By default, the color is vibrant.
if(vibrant == null) vibrant = true;
document.documentElement.style.setProperty('--ac-color', color);
document.documentElement.style.setProperty('--act-color', vibrant ? '#121212' : '#fff');
this.applySetting('THEME_COLOR', color.toUpperCase());
this.applySetting('THEME_COLOR_VIBRANT', vibrant);
},
getActiveColor () {
// This strips extra spaces and # signs from the strings.
const strip = (str) => str.replace(/#/g, '').replace(/ /g, '');
return `#${strip(window.getComputedStyle(document.documentElement).getPropertyValue('--ac-color')).toUpperCase()}`;
},
applySetting (key, value) {
this.settings[key] = value;
this.$store.commit('postwoman/applySetting', [key, value]);
},
toggleSetting (key) {
this.settings[key] = !this.settings[key];
this.$store.commit('postwoman/applySetting', [key, this.settings[key]]);
}
},
beforeMount () {
this.settings.THEME_COLOR = this.getActiveColor();
}
}
</script>

View File

@@ -1,50 +1,45 @@
<template>
<div class="page">
<fieldset class="request" ref="request">
<legend v-on:click="collapse">Request </legend>
<div class="collapsible">
<ul>
<li>
<label for="url">URL</label>
<input type="url" :class="{ error: !urlValid }" v-model="url" @keyup.enter="toggleConnection">
</li>
<li class="no-grow">
<label for="action">&nbsp;</label>
<button class="action" :class="{ disabled: !urlValid }" name="action" @click="toggleConnection">{{ toggleConnectionVerb }}</button>
</li>
</ul>
</div>
</fieldset>
<pw-section class="blue" label="Request" ref="request">
<ul>
<li>
<label for="url">URL</label>
<input id="url" type="url" :class="{ error: !urlValid }" v-model="url" @keyup.enter="toggleConnection">
</li>
<li class="no-grow">
<label>&nbsp;</label>
<button class="action" :class="{ disabled: !urlValid }" name="action" @click="toggleConnection">{{ toggleConnectionVerb }}</button>
</li>
</ul>
</pw-section>
<fieldset class="response" id="response" ref="response">
<legend v-on:click="collapse">Communication </legend>
<div class="collapsible">
<ul>
<li>
<label for="body">Log</label>
<div name="body" class="body" readonly>
<pw-section class="purple" label="Communication" id="response" ref="response">
<ul>
<li>
<label for="log">Log</label>
<div id="log" name="log" class="log" readonly>
<span v-if="communication.log">
<span v-for="logEntry in communication.log" :style="{ color: logEntry.color }">{{ getSourcePrefix(logEntry.source) }} {{ logEntry.payload }}</span>
</span>
<span v-else>(Waiting for connection...)</span>
</div>
</li>
</ul>
<span v-else>(Waiting for connection...)</span>
</div>
</li>
</ul>
<ul>
<li>
<label for="status">Message</label>
<input name="status" type="text" v-model="communication.input" :readonly="!connectionState" @keyup.enter="sendMessage">
</li>
<ul>
<li>
<label for="message">Message</label>
<input id="message" name="message" type="text" v-model="communication.input" :readonly="!connectionState" @keyup.enter="sendMessage">
</li>
<li class="no-grow">
<label>&nbsp;</label>
<button class="action" name="send" :class="{ disabled: !connectionState }" @click="sendMessage">Send</button>
</li>
</ul>
</pw-section>
<li class="no-grow">
<label for="send">&nbsp;</label>
<button class="action" name="send" :class="{ disabled: !connectionState }" @click="sendMessage">Send</button>
</li>
</ul>
</div>
</fieldset>
</div>
</template>
@@ -56,12 +51,12 @@
width: 150px;
}
div.body {
div.log {
margin: 4px;
padding: 8px 16px;
width: calc(100% - 8px);
border-radius: 4px;
background-color: #000;
background-color: var(--bg-dark-color);
color: var(--fg-color);
height: 300px;
overflow: auto;
@@ -80,146 +75,152 @@
</style>
<script>
import section from "../components/section";
export default {
data () {
return {
connectionState: false,
url: "wss://echo.websocket.org",
socket: null,
components: {
'pw-section': section
},
communication: {
log: null,
input: ""
}
}
},
data () {
return {
connectionState: false,
url: "wss://echo.websocket.org",
socket: null,
computed: {
toggleConnectionVerb () {
return !this.connectionState ? "Connect" : "Disconnect";
},
urlValid () {
const pattern = new RegExp('^(wss?:\\/\\/)?' +
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' +
'((\\d{1,3}\\.){3}\\d{1,3}))' +
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' +
'(\\?[;&a-z\\d%_.~+=-]*)?' +
'(\\#[-a-z\\d_]*)?$', 'i');
return pattern.test(this.url);
}
},
methods: {
toggleConnection () {
// If it is connecting:
if(!this.connectionState) return this.connect();
// Otherwise, it's disconnecting.
else return this.disconnect();
},
connect () {
this.communication.log = [
{
payload: `Connecting to ${this.url}...`,
source: 'info',
color: 'lime'
}
];
try {
this.socket = new WebSocket(this.url);
this.socket.onopen = (event) => {
this.connectionState = true;
this.communication.log = [
{
payload: `Connected to ${this.url}.`,
source: 'info',
color: 'lime'
}
];
};
this.socket.onerror = (event) => {
this.handleError();
};
this.socket.onclose = (event) => {
this.connectionState = false;
this.communication.log.push({
payload: `Disconnected from ${this.url}.`,
source: 'info',
color: 'red'
});
};
this.socket.onmessage = (event) => {
this.communication.log.push({
payload: event.data,
source: 'server'
});
}
}catch(ex){
this.handleError(ex);
}
},
disconnect () {
if(this.socket != null) this.socket.close();
},
handleError (error) {
this.disconnect();
this.connectionState = false;
this.communication.log.push({
payload: `An error has occurred.`,
source: 'info',
color: 'red'
});
if(error != null) this.communication.log.push({
payload: error,
source: 'info',
color: 'red'
});
},
sendMessage () {
const message = this.communication.input;
this.socket.send(message);
this.communication.log.push({
payload: message,
source: 'client'
});
this.communication.input = "";
},
collapse({target}) {
const el = target.parentNode.className;
document.getElementsByClassName(el)[0].classList.toggle('hidden');
},
getSourcePrefix(source){
const sourceEmojis = {
// Source used for info messages.
'info': ' [INFO]:\t',
// Source used for client to server messages.
'client': '👽 [SENT]:\t',
// Source used for server to client messages.
'server': '📥 [RECEIVED]:\t'
};
if (Object.keys(sourceEmojis).includes(source)) return sourceEmojis[source];
return '';
communication: {
log: null,
input: ""
}
}
},
computed: {
toggleConnectionVerb () {
return !this.connectionState ? "Connect" : "Disconnect";
},
urlValid () {
const pattern = new RegExp('^(wss?:\\/\\/)?' +
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' +
'((\\d{1,3}\\.){3}\\d{1,3}))' +
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' +
'(\\?[;&a-z\\d%_.~+=-]*)?' +
'(\\#[-a-z\\d_]*)?$', 'i');
return pattern.test(this.url);
}
},
methods: {
toggleConnection () {
// If it is connecting:
if(!this.connectionState) return this.connect();
// Otherwise, it's disconnecting.
else return this.disconnect();
},
connect () {
this.communication.log = [
{
payload: `Connecting to ${this.url}...`,
source: 'info',
color: 'lime'
}
];
try {
this.socket = new WebSocket(this.url);
this.socket.onopen = (event) => {
this.connectionState = true;
this.communication.log = [
{
payload: `Connected to ${this.url}.`,
source: 'info',
color: 'lime'
}
];
};
this.socket.onerror = (event) => {
this.handleError();
};
this.socket.onclose = (event) => {
this.connectionState = false;
this.communication.log.push({
payload: `Disconnected from ${this.url}.`,
source: 'info',
color: 'red'
});
};
this.socket.onmessage = (event) => {
this.communication.log.push({
payload: event.data,
source: 'server'
});
}
}catch(ex){
this.handleError(ex);
}
},
disconnect () {
if(this.socket != null) this.socket.close();
},
handleError (error) {
this.disconnect();
this.connectionState = false;
this.communication.log.push({
payload: `An error has occurred.`,
source: 'info',
color: 'red'
});
if(error != null) this.communication.log.push({
payload: error,
source: 'info',
color: 'red'
});
},
sendMessage () {
const message = this.communication.input;
this.socket.send(message);
this.communication.log.push({
payload: message,
source: 'client'
});
this.communication.input = "";
},
collapse({target}) {
const el = target.parentNode.className;
document.getElementsByClassName(el)[0].classList.toggle('hidden');
},
getSourcePrefix(source){
const sourceEmojis = {
// Source used for info messages.
'info': ' [INFO]:\t',
// Source used for client to server messages.
'client': '👽 [SENT]:\t',
// Source used for server to client messages.
'server': '📥 [RECEIVED]:\t'
};
if (Object.keys(sourceEmojis).includes(source)) return sourceEmojis[source];
return '';
}
}
}
</script>

5
plugins/vuex-persist.js Normal file
View File

@@ -0,0 +1,5 @@
import VuexPersistence from "vuex-persist";
export default ({ store }) => {
new VuexPersistence().plugin(store);
}

3
store/index.js Normal file
View File

@@ -0,0 +1,3 @@
export default {
}

51
store/postwoman.js Normal file
View File

@@ -0,0 +1,51 @@
export const SETTINGS_KEYS = [
/**
* The CSS class that should be applied to the root element.
* Essentially, the name of the background theme.
*/
"THEME_CLASS",
/**
* The hex color code for the currently active theme.
*/
"THEME_COLOR",
/**
* Whether or not THEME_COLOR is considered 'vibrant'.
*
* For readability reasons, if the THEME_COLOR is vibrant,
* any text placed on the theme color will have its color
* inverted from white to black.
*/
"THEME_COLOR_VIBRANT",
/**
* Normally, section frames are multicolored in the UI
* to emphasise the different sections.
* This setting allows that to be turned off.
*/
"DISABLE_FRAME_COLORS"
];
export const state = () => ({
settings: {}
});
export const mutations = {
applySetting (state, setting) {
if(setting == null || !(setting instanceof Array) || setting.length !== 2)
throw new Error("You must provide a setting (array in the form [key, value])");
let key = setting[0];
let value = setting[1];
// Do not just remove this check.
// Add your settings key to the SETTINGS_KEYS array at the
// top of the file.
// This is to ensure that application settings remain documented.
if(!SETTINGS_KEYS.includes(key)) throw new Error("The settings key does not include the key " + key);
state.settings[key] = value;
}
};