first functionable
This commit is contained in:
67
src/App.vue
Normal file
67
src/App.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { useRequestStore } from './stores/requestStore';
|
||||
import RequestPanel from './components/RequestPanel.vue';
|
||||
import ResponsePanel from './components/ResponsePanel.vue';
|
||||
import MethodBadge from './components/MethodBadge.vue';
|
||||
import { History, Layers, Zap } from 'lucide-vue-next';
|
||||
|
||||
const store = useRequestStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen w-full bg-slate-950 text-slate-200 font-sans overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 flex-shrink-0 border-r border-slate-800 flex flex-col bg-slate-950">
|
||||
<!-- Header -->
|
||||
<div class="h-14 flex items-center px-4 border-b border-slate-800 gap-2">
|
||||
<div class="bg-indigo-500/20 p-1.5 rounded-lg">
|
||||
<Zap class="w-5 h-5 text-indigo-400" />
|
||||
</div>
|
||||
<span class="font-bold text-slate-100 tracking-tight">LiteRequest</span>
|
||||
</div>
|
||||
|
||||
<!-- Nav Tabs -->
|
||||
<div class="flex p-2 gap-1">
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-medium bg-slate-900 text-slate-200 rounded border border-slate-800 shadow-sm">
|
||||
<History class="w-3.5 h-3.5" /> History
|
||||
</button>
|
||||
<button class="flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-medium text-slate-500 hover:bg-slate-900 hover:text-slate-300 rounded transition-colors">
|
||||
<Layers class="w-3.5 h-3.5" /> Collections
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-if="store.history.length === 0" class="p-8 text-center text-slate-600 text-xs">
|
||||
No history yet. Make a request!
|
||||
</div>
|
||||
<div v-else class="flex flex-col">
|
||||
<button
|
||||
v-for="item in store.history"
|
||||
:key="item.timestamp"
|
||||
@click="store.loadRequest(item)"
|
||||
class="text-left px-3 py-3 border-b border-slate-800/50 hover:bg-slate-900 transition-colors group flex flex-col gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden w-full">
|
||||
<MethodBadge :method="item.method" />
|
||||
<span class="text-xs text-slate-400 truncate font-mono opacity-75">{{ new Date(item.timestamp).toLocaleTimeString() }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-slate-300 truncate px-1 font-medium" :title="item.url">
|
||||
{{ item.url || 'No URL' }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
<main class="flex-1 flex flex-col min-w-0 bg-slate-900">
|
||||
<div class="flex-1 min-h-0">
|
||||
<RequestPanel />
|
||||
</div>
|
||||
<div class="h-1/2 border-t border-slate-800 min-h-0">
|
||||
<ResponsePanel />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
91
src/components/KeyValueEditor.vue
Normal file
91
src/components/KeyValueEditor.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2, Plus } from 'lucide-vue-next';
|
||||
import type { KeyValue } from '../stores/requestStore';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: KeyValue[];
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const addRow = () => {
|
||||
const newData = [...props.modelValue, {
|
||||
id: crypto.randomUUID(),
|
||||
key: '',
|
||||
value: '',
|
||||
enabled: true
|
||||
}];
|
||||
emit('update:modelValue', newData);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
const newData = [...props.modelValue];
|
||||
newData.splice(index, 1);
|
||||
emit('update:modelValue', newData);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="text-xs text-slate-500 border-b border-slate-800">
|
||||
<th class="pb-2 pl-2 font-medium w-8"></th>
|
||||
<th class="pb-2 font-medium w-1/3">Key</th>
|
||||
<th class="pb-2 font-medium">Value</th>
|
||||
<th class="pb-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in modelValue"
|
||||
:key="item.id"
|
||||
class="group"
|
||||
>
|
||||
<td class="py-1 pl-2 align-middle">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="item.enabled"
|
||||
class="rounded bg-slate-800 border-slate-700 text-indigo-500 focus:ring-indigo-500/50 focus:ring-offset-0"
|
||||
>
|
||||
</td>
|
||||
<td class="py-1 pr-2 align-middle">
|
||||
<input
|
||||
type="text"
|
||||
v-model="item.key"
|
||||
placeholder="Key"
|
||||
class="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-indigo-500 focus:outline-none py-1 px-1 text-sm text-slate-300 transition-colors"
|
||||
>
|
||||
</td>
|
||||
<td class="py-1 pr-2 align-middle">
|
||||
<input
|
||||
type="text"
|
||||
v-model="item.value"
|
||||
placeholder="Value"
|
||||
class="w-full bg-transparent border-b border-transparent hover:border-slate-700 focus:border-indigo-500 focus:outline-none py-1 px-1 text-sm text-slate-300 transition-colors"
|
||||
>
|
||||
</td>
|
||||
<td class="py-1 align-middle text-right">
|
||||
<button
|
||||
@click="removeRow(index)"
|
||||
class="p-1 text-slate-600 hover:text-rose-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button
|
||||
@click="addRow"
|
||||
class="mt-2 flex items-center text-xs text-slate-500 hover:text-indigo-400 transition-colors px-2 py-1"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" /> Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
27
src/components/MethodBadge.vue
Normal file
27
src/components/MethodBadge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
method: string;
|
||||
}>();
|
||||
|
||||
const colors = computed(() => {
|
||||
switch (props.method.toUpperCase()) {
|
||||
case 'GET': return 'bg-sky-500/20 text-sky-400 border-sky-500/30';
|
||||
case 'POST': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
|
||||
case 'PUT': return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||
case 'DELETE': return 'bg-rose-500/20 text-rose-400 border-rose-500/30';
|
||||
case 'PATCH': return 'bg-violet-500/20 text-violet-400 border-violet-500/30';
|
||||
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-bold rounded border backdrop-blur-sm uppercase tracking-wider select-none"
|
||||
:class="colors"
|
||||
>
|
||||
{{ method }}
|
||||
</span>
|
||||
</template>
|
||||
133
src/components/RequestPanel.vue
Normal file
133
src/components/RequestPanel.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRequestStore } from '../stores/requestStore';
|
||||
import KeyValueEditor from './KeyValueEditor.vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Codemirror } from 'vue-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { Play, Loader2 } from 'lucide-vue-next';
|
||||
|
||||
const store = useRequestStore();
|
||||
const activeTab = ref('params');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const extensions = [json(), oneDark];
|
||||
|
||||
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
||||
|
||||
const executeRequest = async () => {
|
||||
if (!store.activeRequest.url) return;
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Prepare headers and params map
|
||||
const headers: Record<string, string> = {};
|
||||
store.activeRequest.headers.filter(h => h.enabled && h.key).forEach(h => headers[h.key] = h.value);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
store.activeRequest.params.filter(p => p.enabled && p.key).forEach(p => params[p.key] = p.value);
|
||||
|
||||
// Execute
|
||||
const response: any = await invoke('execute_request', {
|
||||
method: store.activeRequest.method,
|
||||
url: store.activeRequest.url,
|
||||
headers,
|
||||
body: store.activeRequest.body || null,
|
||||
queryParams: params,
|
||||
});
|
||||
|
||||
// Update Store
|
||||
store.activeRequest.response = {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
body: response.body,
|
||||
time: response.time_elapsed,
|
||||
size: new Blob([response.body]).size
|
||||
};
|
||||
|
||||
// Add to history if successful
|
||||
store.addToHistory(store.activeRequest);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// Simple alert for now, or could use a toast
|
||||
alert('Request Failed: ' + error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-slate-900">
|
||||
<!-- Top Bar -->
|
||||
<div class="p-4 border-b border-slate-800 flex gap-2 items-center">
|
||||
<div class="flex-1 flex items-center bg-slate-950 rounded-lg border border-slate-800 focus-within:border-indigo-500/50 focus-within:ring-1 focus-within:ring-indigo-500/50 transition-all overflow-hidden">
|
||||
<select
|
||||
v-model="store.activeRequest.method"
|
||||
class="bg-transparent text-xs font-bold px-3 py-2 text-slate-300 border-r border-slate-800 focus:outline-none hover:bg-slate-900 cursor-pointer uppercase appearance-none"
|
||||
>
|
||||
<option v-for="m in methods" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
v-model="store.activeRequest.url"
|
||||
placeholder="https://api.example.com/v1/users"
|
||||
class="flex-1 bg-transparent border-none focus:ring-0 text-sm text-slate-200 px-3 py-2 placeholder-slate-600"
|
||||
@keydown.enter="executeRequest"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
@click="executeRequest"
|
||||
:disabled="isLoading"
|
||||
class="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg font-medium text-sm flex items-center gap-2 transition-all shadow-lg shadow-indigo-900/20"
|
||||
>
|
||||
<Loader2 v-if="isLoading" class="w-4 h-4 animate-spin" />
|
||||
<Play v-else class="w-4 h-4 fill-current" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tabs -->
|
||||
<div class="flex border-b border-slate-800">
|
||||
<button
|
||||
v-for="tab in ['params', 'headers', 'body']"
|
||||
:key="tab"
|
||||
@click="activeTab = tab"
|
||||
class="px-4 py-2 text-xs font-medium uppercase tracking-wide border-b-2 transition-colors"
|
||||
:class="activeTab === tab ? 'border-indigo-500 text-indigo-400 bg-indigo-500/5' : 'border-transparent text-slate-500 hover:text-slate-300 hover:bg-slate-800/50'"
|
||||
>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<KeepAlive>
|
||||
<KeyValueEditor
|
||||
v-if="activeTab === 'params'"
|
||||
v-model="store.activeRequest.params"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<KeepAlive>
|
||||
<KeyValueEditor
|
||||
v-if="activeTab === 'headers'"
|
||||
v-model="store.activeRequest.headers"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<div v-if="activeTab === 'body'" class="h-full w-full overflow-hidden">
|
||||
<Codemirror
|
||||
v-model="store.activeRequest.body"
|
||||
placeholder="Request Body (JSON)"
|
||||
:style="{ height: '100%' }"
|
||||
:autofocus="true"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="2"
|
||||
:extensions="extensions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
60
src/components/ResponsePanel.vue
Normal file
60
src/components/ResponsePanel.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRequestStore } from '../stores/requestStore';
|
||||
import { Codemirror } from 'vue-codemirror';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const store = useRequestStore();
|
||||
|
||||
const extensions = [json(), oneDark, EditorView.editable.of(false)];
|
||||
|
||||
const formattedBody = computed(() => {
|
||||
if (!store.activeRequest.response) return '';
|
||||
try {
|
||||
// Auto pretty print
|
||||
return JSON.stringify(JSON.parse(store.activeRequest.response.body), null, 2);
|
||||
} catch (e) {
|
||||
return store.activeRequest.response.body;
|
||||
}
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const s = store.activeRequest.response?.status || 0;
|
||||
if (s >= 200 && s < 300) return 'text-emerald-400';
|
||||
if (s >= 400) return 'text-rose-400';
|
||||
return 'text-amber-400';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full border-t border-slate-800 bg-slate-900">
|
||||
<div v-if="store.activeRequest.response" class="flex flex-col h-full">
|
||||
<!-- Meta Bar -->
|
||||
<div class="px-4 py-2 border-b border-slate-800 flex gap-4 text-xs items-center bg-slate-950/50">
|
||||
<div class="font-mono">
|
||||
Status: <span :class="['font-bold', statusColor]">{{ store.activeRequest.response.status }}</span>
|
||||
</div>
|
||||
<div class="text-slate-500">
|
||||
Time: <span class="text-slate-300">{{ store.activeRequest.response.time }}ms</span>
|
||||
</div>
|
||||
<div class="text-slate-500">
|
||||
Size: <span class="text-slate-300">{{ (store.activeRequest.response.size / 1024).toFixed(2) }} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<Codemirror
|
||||
:model-value="formattedBody"
|
||||
:style="{ height: '100%' }"
|
||||
:extensions="extensions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-slate-600 text-sm">
|
||||
No response yet
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
8
src/main.ts
Normal file
8
src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import "./style.css";
|
||||
import App from "./App.vue";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.mount("#app");
|
||||
99
src/stores/requestStore.ts
Normal file
99
src/stores/requestStore.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
export interface KeyValue {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RequestData {
|
||||
id: string;
|
||||
method: string;
|
||||
url: string;
|
||||
params: KeyValue[];
|
||||
headers: KeyValue[];
|
||||
body: string;
|
||||
response?: {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
time: number;
|
||||
size: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const useRequestStore = defineStore('request', () => {
|
||||
// State
|
||||
const history = ref<RequestData[]>([]);
|
||||
const activeRequest = ref<RequestData>({
|
||||
id: crypto.randomUUID(),
|
||||
method: 'GET',
|
||||
url: '',
|
||||
params: [
|
||||
{ id: crypto.randomUUID(), key: '', value: '', enabled: true }
|
||||
],
|
||||
headers: [
|
||||
{ id: crypto.randomUUID(), key: '', value: '', enabled: true }
|
||||
],
|
||||
body: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Load history from local storage
|
||||
const savedHistory = localStorage.getItem('request_history');
|
||||
if (savedHistory) {
|
||||
try {
|
||||
history.value = JSON.parse(savedHistory);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse history', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
const addToHistory = (req: RequestData) => {
|
||||
// Add to beginning, limit to 50
|
||||
const newEntry = { ...req, timestamp: Date.now() };
|
||||
// Don't store the empty trailing rows in history to save space?
|
||||
// Actually keep them for consistency or filter them. Let's filter empty keys.
|
||||
newEntry.params = newEntry.params.filter(p => p.key.trim() !== '');
|
||||
newEntry.headers = newEntry.headers.filter(h => h.key.trim() !== '');
|
||||
|
||||
history.value.unshift(newEntry);
|
||||
if (history.value.length > 50) {
|
||||
history.value.pop();
|
||||
}
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
history.value = [];
|
||||
};
|
||||
|
||||
const loadRequest = (req: RequestData) => {
|
||||
// Deep copy
|
||||
const loaded = JSON.parse(JSON.stringify(req));
|
||||
loaded.id = crypto.randomUUID();
|
||||
loaded.response = undefined;
|
||||
|
||||
// Ensure at least one empty row for editing
|
||||
if (loaded.params.length === 0) loaded.params.push({ id: crypto.randomUUID(), key: '', value: '', enabled: true });
|
||||
if (loaded.headers.length === 0) loaded.headers.push({ id: crypto.randomUUID(), key: '', value: '', enabled: true });
|
||||
|
||||
activeRequest.value = loaded;
|
||||
};
|
||||
|
||||
// Watch and Save
|
||||
watch(history, (newVal) => {
|
||||
localStorage.setItem('request_history', JSON.stringify(newVal));
|
||||
}, { deep: true });
|
||||
|
||||
return {
|
||||
history,
|
||||
activeRequest,
|
||||
addToHistory,
|
||||
clearHistory,
|
||||
loadRequest
|
||||
};
|
||||
});
|
||||
11
src/style.css
Normal file
11
src/style.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-200 h-screen overflow-hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user