mirror of
https://github.com/lana-k/sqliteviz.git
synced 2025-12-06 18:18:53 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fec8fb5ac0 | ||
|
|
621a41844e | ||
|
|
0a3a94444e | ||
|
|
37aa2d35d5 | ||
|
|
5f91180a8c | ||
|
|
b8c5a2bfd7 | ||
|
|
880c15762b | ||
|
|
df54c9086b | ||
|
|
fdd50b2f86 | ||
|
|
b39a6bdb86 | ||
|
|
5a8b2584ff | ||
|
|
aae47eff86 | ||
|
|
65db2556c0 | ||
|
|
d132127143 | ||
|
|
3e5e4b29c1 | ||
|
|
71c70e0232 | ||
|
|
8f49c0509f | ||
|
|
5e29a051b2 | ||
|
|
8b76258260 | ||
|
|
518270e1f5 |
1202
package-lock.json
generated
1202
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "sqliteviz",
|
||||
"version": "0.0.1",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"build": "NODE_OPTIONS=--max_old_space_size=4096 vue-cli-service build",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
@@ -12,9 +12,9 @@
|
||||
"codemirror": "^5.57.0",
|
||||
"core-js": "^3.6.5",
|
||||
"nanoid": "^3.1.12",
|
||||
"plotly.js": "^1.54.6",
|
||||
"plotly.js": "^1.57.1",
|
||||
"react": "^16.13.1",
|
||||
"react-chart-editor": "^0.41.7",
|
||||
"react-chart-editor": "^0.42.0",
|
||||
"react-dom": "^16.13.1",
|
||||
"sql.js": "^1.3.0",
|
||||
"sqlite-parser": "^1.0.1",
|
||||
|
||||
17
src/assets/images/checkbox_checked.svg
Normal file
17
src/assets/images/checkbox_checked.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="17" height="17" rx="2.5" fill="#119DFF" stroke="#0D76BF"/>
|
||||
<g filter="url(#filter0_d)">
|
||||
<path d="M15.75 5.25L6.75 14.25L2.625 10.125L3.6825 9.0675L6.75 12.1275L14.6925 4.1925L15.75 5.25Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="0.625" y="3.1925" width="17.125" height="14.0575" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.164706 0 0 0 0 0.247059 0 0 0 0 0.372549 0 0 0 0.7 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 981 B |
17
src/assets/images/checkbox_checked_light.svg
Normal file
17
src/assets/images/checkbox_checked_light.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="17" height="17" rx="2.5" fill="#F3F6FA" stroke="#C8D4E3"/>
|
||||
<g filter="url(#filter0_d)">
|
||||
<path d="M15.75 5.24988L6.75 14.2499L2.625 10.1249L3.6825 9.06738L6.75 12.1274L14.6925 4.19238L15.75 5.24988Z" fill="#119DFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="0.625" y="3.19238" width="17.125" height="14.0575" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.164706 0 0 0 0 0.247059 0 0 0 0 0.372549 0 0 0 0.45 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 996 B |
@@ -11,6 +11,7 @@
|
||||
--color-blue-dark: #0D76BF;
|
||||
--color-blue-dark-2: #2A3F5F;
|
||||
--color-red: #EF553B;
|
||||
--color-yellow: #FBEFCB;
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +19,7 @@
|
||||
--color-bg-light-2: var(--color-gray-light-2);
|
||||
--color-bg-light-3: var(--color-gray-light-5);
|
||||
--color-bg-dark: var(--color-gray-dark);
|
||||
--color-bg-warning: var(--color-yellow);
|
||||
--color-accent: var(--color-blue-medium);
|
||||
--color-accent-shade: var(--color-blue-dark);
|
||||
--color-border-light: var(--color-gray-light-2);
|
||||
|
||||
131
src/components/Chart.vue
Normal file
131
src/components/Chart.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="chart-container">
|
||||
<div class="chart-worning" v-show="!sqlResult && visible">
|
||||
There is no data to build a chart. Run your sql query and make sure the result is not empty.
|
||||
</div>
|
||||
<PlotlyEditor
|
||||
v-show="visible"
|
||||
:data="state.data"
|
||||
:layout="state.layout"
|
||||
:frames="state.frames"
|
||||
:config="{ editable: true, displaylogo: false }"
|
||||
:dataSources="dataSources"
|
||||
:dataSourceOptions="dataSourceOptions"
|
||||
:plotly="plotly"
|
||||
@onUpdate="update"
|
||||
:useResizeHandler="true"
|
||||
:debug="true"
|
||||
:advancedTraceTypeSelector="true"
|
||||
class="chart"
|
||||
:style="{ height: !sqlResult ? 'calc(100% - 40px)' : '100%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import plotly from 'plotly.js/dist/plotly'
|
||||
import 'react-chart-editor/lib/react-chart-editor.min.css'
|
||||
|
||||
import PlotlyEditor from 'react-chart-editor'
|
||||
import dereference from 'react-chart-editor/lib/lib/dereference'
|
||||
|
||||
export default {
|
||||
name: 'Chart',
|
||||
props: ['sqlResult', 'initChart', 'visible'],
|
||||
components: {
|
||||
PlotlyEditor
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
plotly: plotly,
|
||||
state: this.initChart || {
|
||||
data: [],
|
||||
layout: {},
|
||||
frames: []
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dataSources () {
|
||||
if (!this.sqlResult) {
|
||||
return {}
|
||||
}
|
||||
const dataSorces = {}
|
||||
const matrix = this.sqlResult.values
|
||||
const [row] = matrix
|
||||
const transposedMatrix = row.map((value, column) => matrix.map(row => row[column]))
|
||||
this.sqlResult.columns.forEach((column, index) => {
|
||||
dataSorces[column] = transposedMatrix[index]
|
||||
})
|
||||
return dataSorces
|
||||
},
|
||||
dataSourceOptions () {
|
||||
return Object.keys(this.dataSources).map(name => ({
|
||||
value: name,
|
||||
label: name
|
||||
}))
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
dataSources () {
|
||||
// we need to update state.data in order to update the graph
|
||||
// https://github.com/plotly/react-chart-editor/issues/948
|
||||
dereference(this.state.data, this.dataSources)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update (data, layout, frames) {
|
||||
this.state = { data, layout, frames }
|
||||
this.$emit('update')
|
||||
},
|
||||
getChartSatateForSave () {
|
||||
// we don't need to save the data, only settings
|
||||
// so we modify state.data using dereference
|
||||
const stateCopy = JSON.parse(JSON.stringify(this.state))
|
||||
const emptySources = {}
|
||||
for (const key in this.dataSources) {
|
||||
emptySources[key] = []
|
||||
}
|
||||
dereference(stateCopy.data, emptySources)
|
||||
return stateCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
height: calc(100% - 89px);
|
||||
}
|
||||
.chart-worning {
|
||||
background-color: var(--color-bg-warning);
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
color: var(--color-text-base);
|
||||
font-size: 13px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.chart {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
>>> .editor_controls .sidebar__item:before {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
>>> .sidebar {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
>>> .editor_controls__wrapper>.panel,
|
||||
>>> .editor_controls .panel__empty {
|
||||
width: 315px;
|
||||
}
|
||||
>>> .editor_controls .sidebar__group__title {
|
||||
padding-left: 10px;
|
||||
}
|
||||
>>> .editor_controls .sidebar__item {
|
||||
padding-left: 32px;
|
||||
}
|
||||
</style>
|
||||
72
src/components/CheckBox.vue
Normal file
72
src/components/CheckBox.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="checkbox-container" @click.stop="onClick">
|
||||
<div v-show="!checked" class="unchecked" />
|
||||
<img
|
||||
v-show="checked && theme === 'accent'"
|
||||
:src="require('@/assets/images/checkbox_checked.svg')"
|
||||
/>
|
||||
<img
|
||||
v-show="checked && theme === 'light'"
|
||||
:src="require('@/assets/images/checkbox_checked_light.svg')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'checkBox',
|
||||
props: {
|
||||
theme: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'accent',
|
||||
validator: (value) => {
|
||||
return ['accent', 'light'].includes(value)
|
||||
}
|
||||
},
|
||||
init: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
checked: this.init
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
checked () {
|
||||
this.$emit('change', this.checked)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClick () {
|
||||
this.checked = !this.checked
|
||||
this.$emit('click', this.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkbox-container {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
.unchecked {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-medium);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.unchecked:hover {
|
||||
background-color: var(--color-bg-light);
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,7 @@
|
||||
<button
|
||||
v-if="$store.state.tabs.length > 0"
|
||||
class="primary"
|
||||
:disabled="!$store.state.currentTab.isUnsaved"
|
||||
:disabled="$store.state.currentTab && !$store.state.currentTab.isUnsaved"
|
||||
@click="saveQuery"
|
||||
>
|
||||
Save
|
||||
@@ -23,6 +23,9 @@ import { nanoid } from 'nanoid'
|
||||
|
||||
export default {
|
||||
name: 'MainMenu',
|
||||
created () {
|
||||
this.$root.$on('createNewQuery', this.createNewQuery)
|
||||
},
|
||||
methods: {
|
||||
createNewQuery () {
|
||||
const tab = {
|
||||
@@ -42,8 +45,8 @@ export default {
|
||||
const isFromScratch = !this.$store.state.currentTab.initName
|
||||
const value = {
|
||||
id: currentQuery.id,
|
||||
query: currentQuery.query
|
||||
// TODO: save plotly settings
|
||||
query: currentQuery.query,
|
||||
chart: currentQuery.getChartSatateForSave()
|
||||
}
|
||||
|
||||
if (isFromScratch) {
|
||||
|
||||
@@ -57,8 +57,7 @@ export default {
|
||||
components: { TableDescription, TextField },
|
||||
data () {
|
||||
return {
|
||||
schemaVisible: true,
|
||||
worker: this.$store.state.worker
|
||||
schemaVisible: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -71,29 +70,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
changeDb () {
|
||||
const dbName = this.$refs.dbfile.value.substr(this.$refs.dbfile.value.lastIndexOf('\\') + 1)
|
||||
this.$store.commit('saveDbName', dbName)
|
||||
const f = this.$refs.dbfile.files[0]
|
||||
const r = new FileReader()
|
||||
r.onload = () => {
|
||||
this.worker.onmessage = () => {
|
||||
const getSchemaSql = `
|
||||
SELECT name, sql
|
||||
FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%';`
|
||||
this.worker.onmessage = event => {
|
||||
this.$store.commit('saveSchema', event.data.results[0].values)
|
||||
}
|
||||
this.worker.postMessage({ action: 'exec', sql: getSchemaSql })
|
||||
}
|
||||
this.$store.commit('saveDbFile', r.result)
|
||||
try {
|
||||
this.worker.postMessage({ action: 'open', buffer: r.result }, [r.result])
|
||||
} catch (exception) {
|
||||
this.worker.postMessage({ action: 'open', buffer: r.result })
|
||||
}
|
||||
}
|
||||
r.readAsArrayBuffer(f)
|
||||
this.$db.loadDb(this.$refs.dbfile.files[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:class="['splitpanes', `splitpanes--${horizontal ? 'horizontal' : 'vertical'}`, { 'splitpanes--dragging': touch.dragging }]"
|
||||
:class="[
|
||||
'splitpanes',
|
||||
`splitpanes--${horizontal ? 'horizontal' : 'vertical'}`,
|
||||
{ 'splitpanes--dragging': touch.dragging }
|
||||
]"
|
||||
>
|
||||
<div class="movable-splitter" ref="movableSplitter" :style="movableSplitterStyle" />
|
||||
<div
|
||||
class="splitpanes__pane"
|
||||
ref="left"
|
||||
@@ -12,13 +17,21 @@
|
||||
>
|
||||
<slot name="left-pane" />
|
||||
</div>
|
||||
|
||||
<splitter
|
||||
<!-- Splitter start-->
|
||||
<div
|
||||
class="splitpanes__splitter"
|
||||
@mousedown="onMouseDown"
|
||||
@toggle="toggleFirstPane"
|
||||
:expanded="paneBefore.size !== 0"
|
||||
/>
|
||||
|
||||
@touchstart="onMouseDown"
|
||||
>
|
||||
<div class="toggle-btn" @click="toggleFirstPane">
|
||||
<img
|
||||
class="direction-icon"
|
||||
:src="require('@/assets/images/chevron.svg')"
|
||||
:style="directionIconStyle"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- splitter end -->
|
||||
<div
|
||||
class="splitpanes__pane"
|
||||
ref="right"
|
||||
@@ -30,33 +43,58 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Splitter from '@/components/splitter'
|
||||
|
||||
export default {
|
||||
name: 'splitpanes',
|
||||
components: { Splitter },
|
||||
name: 'Splitpanes',
|
||||
props: {
|
||||
horizontal: { type: Boolean, default: false },
|
||||
before: { type: Object },
|
||||
after: { type: Object }
|
||||
},
|
||||
data: () => ({
|
||||
container: null,
|
||||
paneBefore: null,
|
||||
paneAfter: null,
|
||||
beforeMinimising: 20,
|
||||
touch: {
|
||||
mouseDown: false,
|
||||
dragging: false
|
||||
data () {
|
||||
return {
|
||||
container: null,
|
||||
paneBefore: this.before,
|
||||
paneAfter: this.after,
|
||||
beforeMinimising: this.before.size,
|
||||
touch: {
|
||||
mouseDown: false,
|
||||
dragging: false
|
||||
},
|
||||
movableSplitter: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
visibility: 'hidden'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
},
|
||||
computed: {
|
||||
styles () {
|
||||
return [
|
||||
{ [this.horizontal ? 'height' : 'width']: `${this.paneBefore.size}%` },
|
||||
{ [this.horizontal ? 'height' : 'width']: `${this.paneAfter.size}%` }
|
||||
]
|
||||
},
|
||||
movableSplitterStyle () {
|
||||
const style = { ...this.movableSplitter }
|
||||
style.top += '%'
|
||||
style.left += '%'
|
||||
return style
|
||||
},
|
||||
expanded () {
|
||||
return this.paneBefore.size !== 0
|
||||
},
|
||||
directionIconStyle () {
|
||||
const translation = 'translate(-50%, -50%)'
|
||||
if (this.horizontal) {
|
||||
return {
|
||||
transform: `${translation} ${this.expanded ? 'rotate(-90deg)' : 'rotate(90deg)'}`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
transform: `${translation} ${this.expanded ? 'rotate(180deg)' : ''}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -92,12 +130,28 @@ export default {
|
||||
// Prevent scrolling while touch dragging (only works with an active event, eg. passive: false).
|
||||
event.preventDefault()
|
||||
this.touch.dragging = true
|
||||
this.calculatePanesSize(this.getCurrentMouseDrag(event))
|
||||
this.$set(this.movableSplitter, 'visibility', 'visible')
|
||||
this.moveSplitter(event)
|
||||
}
|
||||
},
|
||||
|
||||
onMouseUp () {
|
||||
this.touch.mouseDown = false
|
||||
if (this.touch.dragging) {
|
||||
const dragPercentage = this.horizontal
|
||||
? this.movableSplitter.top
|
||||
: this.movableSplitter.left
|
||||
|
||||
this.paneBefore.size = dragPercentage
|
||||
this.paneAfter.size = 100 - dragPercentage
|
||||
|
||||
this.movableSplitter = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
visibility: 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
// Keep dragging flag until click event is finished (click happens immediately after mouseup)
|
||||
// in order to prevent emitting `splitter-click` event if splitter was dragged.
|
||||
setTimeout(() => {
|
||||
@@ -109,8 +163,9 @@ export default {
|
||||
// Get the cursor position relative to the splitpane container.
|
||||
getCurrentMouseDrag (event) {
|
||||
const rect = this.container.getBoundingClientRect()
|
||||
const { clientX, clientY } = ('ontouchstart' in window && event.touches) ? event.touches[0] : event
|
||||
|
||||
const { clientX, clientY } = ('ontouchstart' in window && event.touches)
|
||||
? event.touches[0]
|
||||
: event
|
||||
return {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top
|
||||
@@ -126,27 +181,26 @@ export default {
|
||||
return drag * 100 / containerSize
|
||||
},
|
||||
|
||||
calculatePanesSize (drag) {
|
||||
const dragPercentage = this.getCurrentDragPercentage(drag)
|
||||
// If not pushing other panes, panes to resize are right before and right after splitter.
|
||||
moveSplitter (event) {
|
||||
const dragPercentage = this.getCurrentDragPercentage(this.getCurrentMouseDrag(event))
|
||||
const paneBefore = this.paneBefore
|
||||
const paneAfter = this.paneAfter
|
||||
|
||||
const paneBeforeMaxReached = paneBefore.max < 100 && (dragPercentage >= paneBefore.max)
|
||||
const paneAfterMaxReached = paneAfter.max < 100 && (dragPercentage <= 100 - paneAfter.max)
|
||||
|
||||
const dir = this.horizontal ? 'top' : 'left'
|
||||
|
||||
// Prevent dragging beyond pane max.
|
||||
if (paneBeforeMaxReached || paneAfterMaxReached) {
|
||||
if (paneBeforeMaxReached) {
|
||||
paneBefore.size = paneBefore.max
|
||||
paneAfter.size = Math.max(100 - paneBefore.max, 0)
|
||||
this.$set(this.movableSplitter, dir, paneBefore.max)
|
||||
} else {
|
||||
paneBefore.size = Math.max(100 - paneAfter.max, 0)
|
||||
paneAfter.size = paneAfter.max
|
||||
this.$set(this.movableSplitter, dir, Math.max(100 - paneAfter.max, 0))
|
||||
}
|
||||
return
|
||||
} else {
|
||||
this.$set(this.movableSplitter, dir, Math.min(Math.max(dragPercentage, 0), paneBefore.max))
|
||||
}
|
||||
paneBefore.size = Math.min(Math.max(dragPercentage, 0), paneBefore.max)
|
||||
paneAfter.size = Math.min(Math.max(100 - dragPercentage, 0), paneAfter.max)
|
||||
},
|
||||
toggleFirstPane () {
|
||||
if (this.paneBefore.size > 0) {
|
||||
@@ -160,10 +214,6 @@ export default {
|
||||
},
|
||||
mounted () {
|
||||
this.container = this.$refs.container
|
||||
},
|
||||
created () {
|
||||
this.paneBefore = this.before
|
||||
this.paneAfter = this.after
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -172,6 +222,7 @@ export default {
|
||||
.splitpanes {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.splitpanes--vertical {flex-direction: row;}
|
||||
@@ -183,4 +234,68 @@ export default {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Splitter */
|
||||
|
||||
.splitpanes--vertical > .splitpanes__splitter,
|
||||
.splitpanes--vertical.splitpanes--dragging {
|
||||
cursor: col-resize;
|
||||
}
|
||||
.splitpanes--horizontal > .splitpanes__splitter,
|
||||
.splitpanes--horizontal.splitpanes--dragging {
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.splitpanes__splitter {
|
||||
touch-action: none;
|
||||
background-color: var(--color-bg-light-2);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.movable-splitter {
|
||||
position: absolute;
|
||||
background-color:rgba(162, 177, 198, 0.5);
|
||||
}
|
||||
|
||||
.splitpanes--vertical > .splitpanes__splitter,
|
||||
.splitpanes--vertical .movable-splitter {
|
||||
width: 3px;
|
||||
z-index: 5;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.splitpanes--horizontal > .splitpanes__splitter,
|
||||
.splitpanes--horizontal .movable-splitter {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.splitpanes__splitter .toggle-btn {
|
||||
background-color: var(--color-bg-light-2);
|
||||
border-radius: var(--border-radius-small);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.splitpanes__splitter .toggle-btn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.splitpanes--vertical .toggle-btn {
|
||||
height: 68px;
|
||||
width: 15px;
|
||||
}
|
||||
.splitpanes--horizontal .toggle-btn {
|
||||
width: 68px;
|
||||
height: 15px;
|
||||
}
|
||||
.splitpanes__splitter .toggle-btn .direction-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
</style>
|
||||
82
src/components/SqlEditor.vue
Normal file
82
src/components/SqlEditor.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="codemirror-container">
|
||||
<codemirror v-model="query" :options="cmOptions" @changes="onCmChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CM from 'codemirror'
|
||||
import { codemirror } from 'vue-codemirror'
|
||||
import 'codemirror/lib/codemirror.css'
|
||||
import 'codemirror/mode/sql/sql.js'
|
||||
import 'codemirror/theme/neo.css'
|
||||
import 'codemirror/addon/hint/show-hint.js'
|
||||
import 'codemirror/addon/hint/show-hint.css'
|
||||
import 'codemirror/addon/hint/sql-hint.js'
|
||||
|
||||
export default {
|
||||
name: 'SqlEditor',
|
||||
props: ['value'],
|
||||
components: {
|
||||
codemirror
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
query: this.value,
|
||||
cmOptions: {
|
||||
// codemirror options
|
||||
tabSize: 4,
|
||||
mode: 'text/x-mysql',
|
||||
theme: 'neo',
|
||||
lineNumbers: true,
|
||||
line: true
|
||||
},
|
||||
result: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
query () {
|
||||
this.$emit('input', this.query)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCmChange (editor) {
|
||||
// Don't show autocomplete after a space or semicolon
|
||||
const ch = editor.getTokenAt(editor.getCursor()).string.slice(-1)
|
||||
if (!ch || ch === ' ' || ch === ';') {
|
||||
return
|
||||
}
|
||||
|
||||
const hintOptions = {
|
||||
// tables: this.state.tables,
|
||||
completeSingle: false,
|
||||
completeOnSingleClick: true
|
||||
}
|
||||
|
||||
// editor.hint.sql is defined when importing codemirror/addon/hint/sql-hint
|
||||
// (this is mentioned in codemirror addon documentation)
|
||||
// Reference the hint function imported here when including other hint addons
|
||||
// or supply your own
|
||||
CM.showHint(editor, CM.hint.sql, hintOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codemirror-container {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
>>> .vue-codemirror {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
>>> .CodeMirror {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-big);
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@
|
||||
class="table-container"
|
||||
ref="table-container"
|
||||
@scroll="onScrollTable"
|
||||
:style="{height: `${height}px`}"
|
||||
:style="{maxHeight: `${height}px`}"
|
||||
>
|
||||
<table ref="table">
|
||||
<thead>
|
||||
|
||||
@@ -6,122 +6,89 @@
|
||||
:before="{ size: 50, max: 50 }"
|
||||
:after="{ size: 50, max: 100 }"
|
||||
>
|
||||
<div slot="left-pane" class="query-editor">
|
||||
<div class="codemirror-container">
|
||||
<codemirror v-model="query" :options="cmOptions" @changes="onCmChange" ref="codemirror" />
|
||||
<template #left-pane>
|
||||
<div class="query-editor">
|
||||
<sql-editor v-model="query" />
|
||||
<div class="run-btn-container">
|
||||
<button
|
||||
class="primary run-btn"
|
||||
@click="execute"
|
||||
:disabled="!$store.state.schema || !query"
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="run-btn-container">
|
||||
<button class="primary run-btn" @click="execEditorContents">Run</button>
|
||||
</template>
|
||||
<template #right-pane>
|
||||
<div id="bottomPane" ref="bottomPane">
|
||||
<view-switcher :view.sync="view" />
|
||||
<div v-show="view === 'table'" class="table-view">
|
||||
<div v-show="result === null && !isGettingResults && !error" class="table-preview">
|
||||
Run your query and get results here
|
||||
</div>
|
||||
<div v-show="isGettingResults" class="table-preview">
|
||||
Fetching results...
|
||||
</div>
|
||||
<div v-show="result === undefined && !isGettingResults && !error" class="table-preview">
|
||||
No rows retrieved according to your query
|
||||
</div>
|
||||
<div v-show="error" class="table-preview error">
|
||||
{{ error }}
|
||||
</div>
|
||||
<sql-table v-if="result" :data="result" :height="tableViewHeight" />
|
||||
</div>
|
||||
<chart
|
||||
:visible="view === 'chart'"
|
||||
:sql-result="result"
|
||||
:init-chart="initChart"
|
||||
ref="chart"
|
||||
@update="isUnsaved = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="right-pane" id="bottomPane" ref="bottomPane">
|
||||
<view-switcher :view.sync="view" />
|
||||
<div v-show="view === 'table'" class="table-view">
|
||||
<!-- <div id="error" class="error"></div>
|
||||
<pre ref="output" id="output">Results will be displayed here</pre> -->
|
||||
<sql-table v-if="result" :data="result" :height="tableViewHeight" />
|
||||
</div>
|
||||
<PlotlyEditor
|
||||
v-show="view === 'chart'"
|
||||
:data="state.data"
|
||||
:layout="state.layout"
|
||||
:frames="state.frames"
|
||||
:config="{ editable: true }"
|
||||
:dataSources="dataSources"
|
||||
:dataSourceOptions="dataSourceOptions"
|
||||
:plotly="plotly"
|
||||
@onUpdate="update"
|
||||
:useResizeHandler="true"
|
||||
:debug="true"
|
||||
:advancedTraceTypeSelector="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SqlTable from '@/components/SqlTable'
|
||||
import Splitpanes from '@/components/splitpanes'
|
||||
import SqlEditor from '@/components/SqlEditor'
|
||||
import Splitpanes from '@/components/Splitpanes'
|
||||
import ViewSwitcher from '@/components/ViewSwitcher'
|
||||
|
||||
import plotly from 'plotly.js/dist/plotly'
|
||||
import 'react-chart-editor/lib/react-chart-editor.min.css'
|
||||
|
||||
import CM from 'codemirror'
|
||||
import { codemirror } from 'vue-codemirror'
|
||||
import 'codemirror/lib/codemirror.css'
|
||||
import 'codemirror/mode/sql/sql.js'
|
||||
import 'codemirror/theme/neo.css'
|
||||
import 'codemirror/addon/hint/show-hint.js'
|
||||
import 'codemirror/addon/hint/show-hint.css'
|
||||
import 'codemirror/addon/hint/sql-hint.js'
|
||||
|
||||
import PlotlyEditor from 'react-chart-editor'
|
||||
import Chart from '@/components/Chart'
|
||||
|
||||
export default {
|
||||
name: 'TabContent',
|
||||
props: ['id', 'initName', 'initQuery', 'initPlotly', 'tabIndex'],
|
||||
props: ['id', 'initName', 'initQuery', 'initChart', 'tabIndex'],
|
||||
components: {
|
||||
codemirror,
|
||||
SqlEditor,
|
||||
SqlTable,
|
||||
Splitpanes,
|
||||
ViewSwitcher,
|
||||
PlotlyEditor
|
||||
Chart
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
plotly: plotly,
|
||||
state: {
|
||||
data: [],
|
||||
layout: {},
|
||||
frames: []
|
||||
},
|
||||
query: 'select * from albums',
|
||||
cmOptions: {
|
||||
// codemirror options
|
||||
tabSize: 4,
|
||||
mode: 'text/x-mysql',
|
||||
theme: 'neo',
|
||||
lineNumbers: true,
|
||||
line: true
|
||||
},
|
||||
query: this.initQuery,
|
||||
result: null,
|
||||
view: 'table',
|
||||
tableViewHeight: 0,
|
||||
worker: this.$store.state.worker,
|
||||
isUnsaved: !this.name
|
||||
isUnsaved: !this.initName,
|
||||
isGettingResults: false,
|
||||
error: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isActive () {
|
||||
return this.id === this.$store.state.currentTabId
|
||||
},
|
||||
dataSources () {
|
||||
if (!this.result) {
|
||||
return {}
|
||||
}
|
||||
const dataSorces = {}
|
||||
const matrix = this.result.values
|
||||
const [row] = matrix
|
||||
const transposedMatrix = row.map((value, column) => matrix.map(row => row[column]))
|
||||
this.result.columns.forEach((column, index) => {
|
||||
dataSorces[column] = transposedMatrix[index]
|
||||
})
|
||||
return dataSorces
|
||||
},
|
||||
dataSourceOptions () {
|
||||
return Object.keys(this.dataSources).map(name => ({
|
||||
value: name,
|
||||
label: name
|
||||
}))
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.$store.commit('setCurrentTab', this)
|
||||
},
|
||||
mounted () {
|
||||
new ResizeObserver(this.calculateTableHeight).observe(this.$refs.bottomPane)
|
||||
new ResizeObserver(this.handleResize).observe(this.$refs.bottomPane)
|
||||
this.calculateTableHeight()
|
||||
},
|
||||
watch: {
|
||||
@@ -138,47 +105,32 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update (data, layout, frames) {
|
||||
this.state = { data, layout, frames }
|
||||
this.isUnsaved = true
|
||||
console.log(this.state)
|
||||
},
|
||||
onCmChange (editor) {
|
||||
// Don't show autocomplete after a space or semicolon
|
||||
const ch = editor.getTokenAt(editor.getCursor()).string.slice(-1)
|
||||
if (!ch || ch === ' ' || ch === ';') {
|
||||
return
|
||||
}
|
||||
|
||||
const hintOptions = {
|
||||
// tables: this.state.tables,
|
||||
completeSingle: false,
|
||||
completeOnSingleClick: true
|
||||
}
|
||||
|
||||
// editor.hint.sql is defined when importing codemirror/addon/hint/sql-hint
|
||||
// (this is mentioned in codemirror addon documentation)
|
||||
// Reference the hint function imported here when including other hint addons
|
||||
// or supply your own
|
||||
CM.showHint(editor, CM.hint.sql, hintOptions)
|
||||
},
|
||||
// Run a command in the database
|
||||
execute (commands) {
|
||||
this.worker.onmessage = (event) => {
|
||||
// if it was more than one select - take only the first one
|
||||
this.result = event.data.results[0]
|
||||
if (!this.result) {
|
||||
console.log(event.data.error)
|
||||
// return
|
||||
}
|
||||
|
||||
// this.$refs.output.innerHTML = ''
|
||||
}
|
||||
this.worker.postMessage({ action: 'exec', sql: commands })
|
||||
// this.$refs.output.textContent = 'Fetching results...'
|
||||
execute () {
|
||||
// this.$refs.output.textContent = 'Fetching results...' */
|
||||
this.isGettingResults = true
|
||||
this.result = null
|
||||
this.error = null
|
||||
this.$db.execute(this.query + ';')
|
||||
.then(result => {
|
||||
this.result = result
|
||||
})
|
||||
.catch(err => {
|
||||
this.error = err
|
||||
})
|
||||
.finally(() => {
|
||||
this.isGettingResults = false
|
||||
})
|
||||
},
|
||||
execEditorContents () {
|
||||
this.execute(this.query + ';')
|
||||
handleResize () {
|
||||
if (this.view === 'chart') {
|
||||
// hack react-chart editor: hidden and show in order to make the graph resize
|
||||
this.view = 'not chart'
|
||||
this.$nextTick(() => {
|
||||
this.view = 'chart'
|
||||
})
|
||||
}
|
||||
this.calculateTableHeight()
|
||||
},
|
||||
calculateTableHeight () {
|
||||
const bottomPane = this.$refs.bottomPane
|
||||
@@ -189,6 +141,9 @@ export default {
|
||||
// 40 - height of table header
|
||||
const freeSpace = bottomPane.offsetHeight - 88 - 42 - 30 - 5 - 40
|
||||
this.tableViewHeight = freeSpace - (freeSpace % 40)
|
||||
},
|
||||
getChartSatateForSave () {
|
||||
return this.$refs.chart.getChartSatateForSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,29 +177,33 @@ export default {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.codemirror-container {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.run-btn-container {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
>>> .vue-codemirror {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
>>> .CodeMirror {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-big);
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.table-view {
|
||||
margin: 0 52px;
|
||||
height: calc(100% - 88px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-preview {
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--color-text-base);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-preview.error {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
.table-preview.error::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<div id="tabs__header">
|
||||
<div id="tabs-container">
|
||||
<div id="tabs__header" v-if="tabs.length > 0">
|
||||
<div
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.id"
|
||||
@@ -34,8 +34,15 @@
|
||||
:key="tab.id"
|
||||
:id="tab.id"
|
||||
:init-name="tab.name"
|
||||
:init-query="tab.query"
|
||||
:init-chart="tab.chart"
|
||||
:tab-index="index"
|
||||
/>
|
||||
<div v-if="tabs.length === 0" id="start-guide">
|
||||
<span class="link" @click="$root.$emit('createNewQuery')">Create</span>
|
||||
a new query from scratch or open the one from
|
||||
<router-link class="link" to="/my-queries">My queries</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,6 +77,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#tabs-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
#tabs__header {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
@@ -129,4 +140,18 @@ export default {
|
||||
fill: var(--color-text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
#start-guide {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--color-text-base);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="splitpanes__splitter"
|
||||
@mousedown="$emit('mousedown')"
|
||||
@touchstart="$emit('mousedown')"
|
||||
>
|
||||
<div class="toggle-btn" @click="$emit('toggle')">
|
||||
<img
|
||||
class="direction-icon"
|
||||
:src="require('@/assets/images/chevron.svg')"
|
||||
:style="directionIconStyle"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'splitter',
|
||||
props: ['expanded'],
|
||||
computed: {
|
||||
directionIconStyle () {
|
||||
const translation = 'translate(-50%, -50%)'
|
||||
if (this.$parent.horizontal) {
|
||||
return {
|
||||
transform: `${translation} ${this.expanded ? 'rotate(-90deg)' : 'rotate(90deg)'}`
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
transform: `${translation} ${this.expanded ? 'rotate(180deg)' : ''}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.splitpanes--vertical > .splitpanes__splitter {min-width: 1px;cursor: col-resize;}
|
||||
.splitpanes--horizontal > .splitpanes__splitter {min-height: 1px; cursor: row-resize;}
|
||||
.splitpanes__splitter {
|
||||
touch-action: none;
|
||||
background-color: var(--color-bg-light-2);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.splitpanes--vertical > .splitpanes__splitter {
|
||||
width: 3px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.splitpanes--horizontal > .splitpanes__splitter {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
.splitpanes__splitter .toggle-btn {
|
||||
background-color: var(--color-bg-light-2);
|
||||
border-radius: var(--border-radius-small);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.splitpanes__splitter .toggle-btn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.splitpanes--vertical .toggle-btn {
|
||||
height: 68px;
|
||||
width: 15px;
|
||||
}
|
||||
.splitpanes--horizontal .toggle-btn {
|
||||
width: 68px;
|
||||
height: 15px;
|
||||
}
|
||||
.splitpanes__splitter .toggle-btn .direction-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
</style>
|
||||
46
src/dataBase.js
Normal file
46
src/dataBase.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import store from '@/store'
|
||||
import router from '@/router'
|
||||
const worker = new Worker('js/worker.sql-wasm.js')
|
||||
|
||||
export default {
|
||||
loadDb (file) {
|
||||
const dbName = file.name
|
||||
store.commit('saveDbName', dbName)
|
||||
const f = file
|
||||
const r = new FileReader()
|
||||
r.onload = () => {
|
||||
worker.onmessage = () => {
|
||||
const getSchemaSql = `
|
||||
SELECT name, sql
|
||||
FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%';`
|
||||
worker.onmessage = event => {
|
||||
store.commit('saveSchema', event.data.results[0].values)
|
||||
if (router.currentRoute.path !== '/editor') {
|
||||
router.push('/editor')
|
||||
}
|
||||
}
|
||||
worker.postMessage({ action: 'exec', sql: getSchemaSql })
|
||||
}
|
||||
store.commit('saveDbFile', r.result)
|
||||
try {
|
||||
worker.postMessage({ action: 'open', buffer: r.result }, [r.result])
|
||||
} catch (exception) {
|
||||
worker.postMessage({ action: 'open', buffer: r.result })
|
||||
}
|
||||
}
|
||||
r.readAsArrayBuffer(f)
|
||||
},
|
||||
execute (commands) {
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.error) {
|
||||
reject(event.data.error)
|
||||
}
|
||||
// if it was more than one select - take only the first one
|
||||
resolve(event.data.results[0])
|
||||
}
|
||||
worker.postMessage({ action: 'exec', sql: commands })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import router from './router'
|
||||
import store from './store'
|
||||
import { VuePlugin } from 'vuera'
|
||||
import VModal from 'vue-js-modal'
|
||||
import db from '@/dataBase'
|
||||
|
||||
import '@/assets/styles/variables.css'
|
||||
import '@/assets/styles/buttons.css'
|
||||
@@ -14,6 +15,7 @@ Vue.use(VuePlugin)
|
||||
Vue.use(VModal)
|
||||
|
||||
Vue.config.productionTip = false
|
||||
Vue.prototype.$db = db
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
|
||||
@@ -8,7 +8,6 @@ export default new Vuex.Store({
|
||||
schema: null,
|
||||
dbFile: null,
|
||||
dbName: null,
|
||||
worker: new Worker('js/worker.sql-wasm.js'),
|
||||
tabs: [],
|
||||
currentTab: null,
|
||||
currentTabId: null,
|
||||
|
||||
@@ -16,47 +16,22 @@
|
||||
</div>
|
||||
</label>
|
||||
<div id="error" class="error"></div>
|
||||
<button id ="skip" class="secondary" @click="$router.push('/editor')">
|
||||
Skip database connection for now
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DbUpload',
|
||||
data () {
|
||||
return {
|
||||
worker: this.$store.state.worker
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadDb () {
|
||||
const dbName = this.$refs.file.value.substr(this.$refs.file.value.lastIndexOf('\\') + 1)
|
||||
this.$store.commit('saveDbName', dbName)
|
||||
const f = this.$refs.file.files[0]
|
||||
const r = new FileReader()
|
||||
r.onload = () => {
|
||||
this.worker.onmessage = () => {
|
||||
const getSchemaSql = `
|
||||
SELECT name, sql
|
||||
FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%';`
|
||||
this.worker.onmessage = event => {
|
||||
this.$store.commit('saveSchema', event.data.results[0].values)
|
||||
this.$router.push('/editor')
|
||||
}
|
||||
this.worker.postMessage({ action: 'exec', sql: getSchemaSql })
|
||||
}
|
||||
this.$store.commit('saveDbFile', r.result)
|
||||
try {
|
||||
this.worker.postMessage({ action: 'open', buffer: r.result }, [r.result])
|
||||
} catch (exception) {
|
||||
this.worker.postMessage({ action: 'open', buffer: r.result })
|
||||
}
|
||||
}
|
||||
r.readAsArrayBuffer(f)
|
||||
this.$db.loadDb(this.$refs.file.files[0])
|
||||
},
|
||||
dragover (event) {
|
||||
event.preventDefault()
|
||||
// TODO: Add some visual fluff to show the user can drop its files
|
||||
// TODO: Add some visual stuff to show the user can drop its files
|
||||
if (!event.currentTarget.classList.contains('bg-green-300')) {
|
||||
event.currentTarget.classList.remove('bg-gray-100')
|
||||
event.currentTarget.classList.add('bg-green-300')
|
||||
@@ -98,17 +73,21 @@ label {
|
||||
border-radius: var(--border-radius-big);
|
||||
}
|
||||
#drop-area {
|
||||
width: 231px;
|
||||
height: 153px;
|
||||
width: 628px;
|
||||
height: 490px;
|
||||
background-color: var(--color-bg-light-3);
|
||||
border-radius: var(--border-radius-big);
|
||||
color: var(--color-text-base);
|
||||
font-size: 13px;
|
||||
padding: 44px 15px;
|
||||
padding: 200px 144px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
#skip {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
:before="{ size: 20, max: 30 }"
|
||||
:after="{ size: 80, max: 100 }"
|
||||
>
|
||||
<div slot="left-pane">
|
||||
<template #left-pane>
|
||||
<schema />
|
||||
</div>
|
||||
<div slot="right-pane">
|
||||
</template>
|
||||
<template #right-pane>
|
||||
<tabs />
|
||||
</div>
|
||||
</template>
|
||||
</splitpanes>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Splitpanes from '@/components/splitpanes'
|
||||
import Splitpanes from '@/components/Splitpanes'
|
||||
import Schema from '@/components/Schema'
|
||||
import Tabs from '@/components/Tabs'
|
||||
|
||||
|
||||
@@ -15,18 +15,31 @@
|
||||
Import
|
||||
</label>
|
||||
</button>
|
||||
<button class="toolbar" v-show="selectedQueries.length > 0">Export</button>
|
||||
<button class="toolbar" v-show="selectedQueries.length > 0">Delete</button>
|
||||
<button
|
||||
class="toolbar"
|
||||
v-show="selectedQueriesCount > 0"
|
||||
@click="exportQuery(selectedQueriesIds)"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
class="toolbar"
|
||||
v-show="selectedQueriesCount > 0"
|
||||
@click="showDeleteDialog(selectedQueriesIds)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div id="toolbar-search">
|
||||
<text-field placeholder="Search query by name" width="300px"/>
|
||||
<text-field placeholder="Search query by name" width="300px" v-model="filter"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-bg">
|
||||
<div class="header-container">
|
||||
<div>
|
||||
<div class="fixed-header" ref="name-th">
|
||||
Name
|
||||
<check-box ref="mainCheckBox" theme="light" @click="toggleSelectAll"/>
|
||||
<div class="name-th">Name</div>
|
||||
</div>
|
||||
<div class="fixed-header">
|
||||
Created at
|
||||
@@ -39,18 +52,25 @@
|
||||
>
|
||||
<table ref="table">
|
||||
<tbody>
|
||||
<tr v-for="(query, index) in queries" :key="query.id" @click="openQuery(index)">
|
||||
<tr v-for="(query, index) in showedQueries" :key="query.id" @click="openQuery(index)">
|
||||
<td ref="name-td">
|
||||
{{ query.name }}
|
||||
<div class="cell-data">
|
||||
<check-box
|
||||
ref="rowCheckBox"
|
||||
:init="selectAll || selectedQueriesIds.has(query.id)"
|
||||
@change="toggleRow($event, query.id)"
|
||||
/>
|
||||
<div class="name">{{ query.name }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="second-column">
|
||||
<div class="date-container">{{ query.createdAt | date }}</div>
|
||||
<div class="icons-container">
|
||||
<rename-icon @click="showRenameDialog(index)" />
|
||||
<rename-icon @click="showRenameDialog(query.id)" />
|
||||
<copy-icon @click="duplicateQuery(index)"/>
|
||||
<export-icon @click="exportQuery(index)"/>
|
||||
<delete-icon @click="showDeleteDialog(index)"/>
|
||||
<delete-icon @click="showDeleteDialog(query.id)"/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -84,19 +104,24 @@
|
||||
<!--Delete Query dialog -->
|
||||
<modal name="delete" classes="dialog" height="auto">
|
||||
<div class="dialog-header">
|
||||
Delete query
|
||||
Delete {{ deleteGroup ? 'queries' : 'query' }}
|
||||
<close-icon @click="$modal.hide('delete')"/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
currentQueryIndex !== null
|
||||
&& currentQueryIndex >= 0
|
||||
&& currentQueryIndex < queries.length
|
||||
deleteGroup || (
|
||||
currentQueryIndex !== null
|
||||
&& currentQueryIndex >= 0
|
||||
&& currentQueryIndex < queries.length
|
||||
)
|
||||
"
|
||||
class="dialog-body"
|
||||
>
|
||||
Are you sure you want to delete
|
||||
"{{ queries[currentQueryIndex].name }}"?
|
||||
{{ deleteGroup
|
||||
? `${selectedQueriesCount} ${selectedQueriesCount > 1 ? 'queries' : 'query'}`
|
||||
: `"${queries[currentQueryIndex].name}"`
|
||||
}}?
|
||||
</div>
|
||||
<div class="dialog-buttons-container">
|
||||
<button class="secondary" @click="$modal.hide('delete')">Cancel</button>
|
||||
@@ -114,6 +139,7 @@ import ExportIcon from '@/components/svg/export'
|
||||
import DeleteIcon from '@/components/svg/delete'
|
||||
import CloseIcon from '@/components/svg/close'
|
||||
import TextField from '@/components/TextField'
|
||||
import CheckBox from '@/components/CheckBox'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
export default {
|
||||
@@ -124,15 +150,32 @@ export default {
|
||||
ExportIcon,
|
||||
DeleteIcon,
|
||||
CloseIcon,
|
||||
TextField
|
||||
TextField,
|
||||
CheckBox
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
queries: [],
|
||||
filter: null,
|
||||
newName: null,
|
||||
currentQueryIndex: null,
|
||||
currentQueryId: null,
|
||||
errorMsg: null,
|
||||
selectedQueries: []
|
||||
selectedQueriesIds: new Set(),
|
||||
selectedQueriesCount: 0,
|
||||
selectAll: false,
|
||||
deleteGroup: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showedQueries () {
|
||||
if (!this.filter) {
|
||||
return this.queries
|
||||
} else {
|
||||
return this.queries.filter(query => query.name.toUpperCase().indexOf(this.filter.toUpperCase()) >= 0)
|
||||
}
|
||||
},
|
||||
currentQueryIndex () {
|
||||
return this.queries.findIndex(query => query.id === this.currentQueryId)
|
||||
}
|
||||
},
|
||||
created () {
|
||||
@@ -162,15 +205,15 @@ export default {
|
||||
this.$refs['name-th'].style = `width: ${this.$refs['name-td'][0].offsetWidth}px`
|
||||
},
|
||||
openQuery (index) {
|
||||
const tab = this.queries[index]
|
||||
const tab = this.showedQueries[index]
|
||||
tab.isUnsaved = false
|
||||
this.$store.commit('addTab', tab)
|
||||
this.$store.commit('setCurrentTabId', tab.id)
|
||||
this.$router.push('/editor')
|
||||
},
|
||||
showRenameDialog (index) {
|
||||
showRenameDialog (id) {
|
||||
this.errorMsg = null
|
||||
this.currentQueryIndex = index
|
||||
this.currentQueryId = id
|
||||
this.newName = this.queries[this.currentQueryIndex].name
|
||||
this.$modal.show('rename')
|
||||
},
|
||||
@@ -190,41 +233,82 @@ export default {
|
||||
}
|
||||
},
|
||||
duplicateQuery (index) {
|
||||
const newQuery = JSON.parse(JSON.stringify(this.queries[index]))
|
||||
const newQuery = JSON.parse(JSON.stringify(this.showedQueries[index]))
|
||||
newQuery.name = newQuery.name + ' Copy'
|
||||
newQuery.id = nanoid()
|
||||
newQuery.createdAt = new Date()
|
||||
this.queries.push(newQuery)
|
||||
if (this.selectAll) {
|
||||
this.selectedQueriesIds.add(newQuery.id)
|
||||
this.selectedQueriesCount = this.selectedQueriesIds.size
|
||||
}
|
||||
this.saveQueriesInLocalStorage()
|
||||
},
|
||||
showDeleteDialog (index) {
|
||||
this.currentQueryIndex = index
|
||||
showDeleteDialog (id) {
|
||||
this.deleteGroup = typeof id !== 'string'
|
||||
if (!this.deleteGroup) {
|
||||
this.currentQueryId = id
|
||||
}
|
||||
this.$modal.show('delete')
|
||||
},
|
||||
deleteQuery () {
|
||||
this.$modal.hide('delete')
|
||||
const id = this.queries[this.currentQueryIndex].id
|
||||
this.queries.splice(this.currentQueryIndex, 1)
|
||||
this.saveQueriesInLocalStorage()
|
||||
const tabIndex = this.findTabIndex(id)
|
||||
if (tabIndex >= 0) {
|
||||
this.$store.commit('deleteTab', tabIndex)
|
||||
if (!this.deleteGroup) {
|
||||
this.queries.splice(this.currentQueryIndex, 1)
|
||||
const tabIndex = this.findTabIndex(this.currentQueryId)
|
||||
if (tabIndex >= 0) {
|
||||
this.$store.commit('deleteTab', tabIndex)
|
||||
}
|
||||
if (this.selectedQueriesIds.has(this.currentQueryId)) {
|
||||
this.selectedQueriesIds.delete(this.currentQueryId)
|
||||
}
|
||||
} else {
|
||||
this.queries = this.selectAll
|
||||
? []
|
||||
: this.queries.filter(query => !this.selectedQueriesIds.has(query.id))
|
||||
const tabs = this.$store.state.tabs
|
||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||
if (this.selectedQueriesIds.has(tabs[i].id)) {
|
||||
this.$store.commit('deleteTab', i)
|
||||
}
|
||||
}
|
||||
this.selectedQueriesIds.clear()
|
||||
}
|
||||
this.selectedQueriesCount = this.selectedQueriesIds.size
|
||||
this.saveQueriesInLocalStorage()
|
||||
},
|
||||
findTabIndex (id) {
|
||||
return this.$store.state.tabs.findIndex(tab => tab.id === id)
|
||||
},
|
||||
exportQuery (index) {
|
||||
this.currentQueryIndex = index
|
||||
let data
|
||||
let name
|
||||
|
||||
// single operation
|
||||
if (typeof index === 'number') {
|
||||
console.log('single')
|
||||
data = JSON.parse(JSON.stringify(this.showedQueries[index]))
|
||||
name = data.name
|
||||
delete data.id
|
||||
delete data.createdAt
|
||||
} else {
|
||||
// group operation
|
||||
data = this.selectAll
|
||||
? JSON.parse(JSON.stringify(this.queries))
|
||||
: this.queries.filter(query => this.selectedQueriesIds.has(query.id))
|
||||
name = 'My sqliteviz queries'
|
||||
data.forEach(query => {
|
||||
delete query.id
|
||||
delete query.createdAt
|
||||
})
|
||||
}
|
||||
// export data to file
|
||||
const downloader = this.$refs.downloader
|
||||
const currentQuery = JSON.parse(JSON.stringify(this.queries[this.currentQueryIndex]))
|
||||
delete currentQuery.id
|
||||
delete currentQuery.createdAt
|
||||
const json = JSON.stringify(currentQuery)
|
||||
const json = JSON.stringify(data, null, 4)
|
||||
const blob = new Blob([json], { type: 'octet/stream' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
downloader.href = url
|
||||
downloader.download = `${currentQuery.name}.json`
|
||||
downloader.download = `${name}.json`
|
||||
downloader.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
@@ -241,6 +325,10 @@ export default {
|
||||
importedQueries.forEach(query => {
|
||||
query.id = nanoid()
|
||||
query.createdAt = new Date()
|
||||
if (this.selectAll) {
|
||||
this.selectedQueriesIds.add(query.id)
|
||||
this.selectedQueriesCount = this.selectedQueriesIds.size
|
||||
}
|
||||
})
|
||||
|
||||
this.queries = this.queries.concat(importedQueries)
|
||||
@@ -251,6 +339,24 @@ export default {
|
||||
},
|
||||
saveQueriesInLocalStorage () {
|
||||
localStorage.setItem('myQueries', JSON.stringify(this.queries))
|
||||
},
|
||||
toggleSelectAll (checked) {
|
||||
this.selectAll = checked
|
||||
this.$refs.rowCheckBox.forEach(item => { item.checked = checked })
|
||||
this.selectedQueriesIds = checked ? new Set(this.queries.map(query => query.id)) : new Set()
|
||||
this.selectedQueriesCount = this.selectedQueriesIds.size
|
||||
},
|
||||
toggleRow (checked, id) {
|
||||
if (checked) {
|
||||
this.selectedQueriesIds.add(id)
|
||||
} else {
|
||||
if (this.selectedQueriesIds.size === this.queries.length) {
|
||||
this.$refs.mainCheckBox.checked = false
|
||||
this.selectAll = false
|
||||
}
|
||||
this.selectedQueriesIds.delete(id)
|
||||
}
|
||||
this.selectedQueriesCount = this.selectedQueriesIds.size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,24 +381,43 @@ export default {
|
||||
max-width: 1500px;
|
||||
width: 100%;
|
||||
}
|
||||
.fixed-header:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.fixed-header:first-child .name-th {
|
||||
margin-left: 24px;
|
||||
}
|
||||
table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
tbody tr td {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 24px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
tbody tr td:first-child {
|
||||
width: 70%;
|
||||
max-width: 0;
|
||||
padding: 0 12px;
|
||||
}
|
||||
tbody tr td:last-child {
|
||||
width: 30%;
|
||||
max-width: 0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
tbody .cell-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
tbody .cell-data div.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
|
||||
Reference in New Issue
Block a user