ADD - initial commit

This commit is contained in:
Eichenholz
2026-08-03 15:10:43 +02:00
parent f43223a250
commit 1dd1d05afd
32 changed files with 3568 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
@AGENTS.md
+25
View File
@@ -0,0 +1,25 @@
# sky_phone
FiveM phone scaffold for Sky-Systems, using Vue 3 and Konsta UI.
## Development
```powershell
cd frontend
pnpm install
pnpm dev
```
The development command starts Vite and a small mock server for NUI callbacks. The phone opens
automatically in browser development mode.
## Build
```powershell
cd frontend
pnpm build
```
The production build is published to `sky_phone/source/html/`. In FiveM, ensure `sky_base` before
`sky_phone` and use `/phone` to toggle the UI.
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.local
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all"
}
+31
View File
@@ -0,0 +1,31 @@
const fs = require('node:fs')
const path = require('node:path')
const frontendRoot = __dirname
const sourceDirectory = path.join(frontendRoot, 'dist')
const targetDirectory = path.join(
frontendRoot,
'..',
'sky_phone',
'source',
'html',
)
if (!fs.existsSync(sourceDirectory)) {
throw new Error(
'Missing frontend/dist. Run vite build before publishing the NUI.',
)
}
fs.rmSync(targetDirectory, { force: true, recursive: true })
fs.mkdirSync(targetDirectory, { recursive: true })
fs.cpSync(sourceDirectory, targetDirectory, { recursive: true })
const targetIndex = path.join(targetDirectory, 'index.html')
const normalizedIndex = fs
.readFileSync(targetIndex, 'utf8')
.replace(/\r\n?/g, '\n')
.replace(/\n[ \t]*\n([ \t]*<\/body>)/g, '\n$1')
fs.writeFileSync(targetIndex, normalizedIndex)
console.log(`Published NUI to ${targetDirectory}`)
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface Window {
GetParentResourceName?: () => string
}
+13
View File
@@ -0,0 +1,13 @@
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import { globalIgnores } from 'eslint/config'
import pluginVue from 'eslint-plugin-vue'
export default defineConfigWithVueTs(
{ files: ['**/*.{ts,mts,tsx,vue}'], name: 'app/files-to-lint' },
globalIgnores(['**/dist/**', 'testserver/**', 'build.cjs']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "sky-phone-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite\" \"node testserver/index.cjs\"",
"build": "pnpm typecheck && pnpm build-only",
"build-only": "vite build && node build.cjs",
"preview": "vite preview",
"typecheck": "vue-tsc --build",
"lint": "eslint .",
"format": "prettier --write src/ testserver/ build.cjs",
"test": "node --test"
},
"dependencies": {
"konsta": "~5.2.0",
"pinia": "^3.0.3",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.15.32",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.1",
"@vue/tsconfig": "^0.7.0",
"concurrently": "^9.2.0",
"cors": "^2.8.5",
"eslint": "^9.29.0",
"eslint-plugin-vue": "~10.2.0",
"prettier": "3.5.3",
"tailwindcss": "^4.0.0",
"typescript": "~5.8.0",
"vite": "^7.0.0",
"vue-tsc": "^2.2.10"
}
}
+2876
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { kApp } from 'konsta/vue'
import { onBeforeUnmount, onMounted } from 'vue'
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
type AppMessage = {
type?: string
data?: PhoneOpenPayload
}
const phone = usePhoneStore()
function onMessage(event: MessageEvent<AppMessage>): void {
if (event.data?.type === 'app:open') {
phone.open(event.data.data)
} else if (event.data?.type === 'app:close') {
phone.close()
}
}
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || !phone.isOpen) return
phone.close()
void nuiCall('close')
}
onMounted(() => {
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
void nuiCall('ui:ready')
if (import.meta.env.DEV) {
phone.open()
}
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<main v-if="phone.isOpen" class="phone-stage">
<section class="phone-device" aria-label="Phone preview">
<div class="phone-device__speaker" aria-hidden="true"></div>
<div class="phone-screen">
<k-app theme="ios" safe-areas class="phone-app">
<RouterView />
</k-app>
</div>
</section>
</main>
</template>
+111
View File
@@ -0,0 +1,111 @@
@import 'tailwindcss';
@import 'konsta/vue/theme.css';
@theme {
--color-brand-primary: #007aff;
}
:root {
color-scheme: light;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Arial,
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
user-select: none;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
.phone-stage {
position: fixed;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
.phone-device {
position: relative;
width: min(39vh, 390px);
aspect-ratio: 390 / 844;
padding: min(1.05vh, 10px);
overflow: hidden;
pointer-events: auto;
background: linear-gradient(145deg, #303238 0%, #090a0d 38%, #25272d 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: min(5.2vh, 52px);
box-shadow:
0 4vh 10vh rgba(0, 0, 0, 0.55),
inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.phone-device__speaker {
position: absolute;
top: min(2.1vh, 21px);
left: 50%;
z-index: 20;
width: min(9vh, 90px);
height: min(2.8vh, 28px);
transform: translateX(-50%);
background: #08090b;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
box-shadow: inset 0 -1px 1px rgba(255, 255, 255, 0.05);
}
.phone-screen {
width: 100%;
height: 100%;
overflow: hidden;
background: #f2f2f7;
border-radius: min(4.25vh, 42px);
}
.phone-app {
width: 100%;
height: 100%;
}
.phone-home {
height: 100%;
padding-top: min(2.8vh, 28px);
background:
radial-gradient(
circle at 20% 22%,
rgba(66, 165, 245, 0.28),
transparent 38%
),
radial-gradient(
circle at 80% 58%,
rgba(175, 82, 222, 0.2),
transparent 42%
),
linear-gradient(160deg, #f8fbff 0%, #edf0f8 48%, #f8f3fb 100%);
}
.phone-home__content {
display: grid;
gap: 1.5rem;
align-content: start;
padding-top: 1rem;
}
+8
View File
@@ -0,0 +1,8 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
createApp(App).use(createPinia()).use(router).mount('#app')
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import PhoneHomeView from '@/views/PhoneHomeView.vue'
export default createRouter({
history: createWebHashHistory(),
routes: [
{
component: PhoneHomeView,
path: '/',
},
],
})
+56
View File
@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
type LocaleTree = Record<string, unknown>
export type PhoneOpenPayload = {
lang?: string
locales?: LocaleTree
}
const defaultLocales: LocaleTree = {
Phone: {
close: 'Close',
readyBody: 'Konsta UI is connected to the FiveM NUI bridge.',
readyTitle: 'Boilerplate ready',
title: 'Phone',
},
}
function getByPath(source: LocaleTree, path: string): unknown {
return path.split('.').reduce<unknown>((current, segment) => {
if (
typeof current !== 'object' ||
current === null ||
Array.isArray(current)
)
return undefined
return (current as LocaleTree)[segment]
}, source)
}
export const usePhoneStore = defineStore('phone', {
state: () => ({
isOpen: false,
lang: 'en',
locales: defaultLocales,
}),
actions: {
close(): void {
this.isOpen = false
},
open(payload: PhoneOpenPayload = {}): void {
this.lang = payload.lang ?? 'en'
this.locales = payload.locales ?? defaultLocales
this.isOpen = true
},
t(path: string): string {
const translated = getByPath(this.locales, path)
const fallback = getByPath(defaultLocales, path)
return typeof translated === 'string'
? translated
: typeof fallback === 'string'
? fallback
: path
},
},
})
+37
View File
@@ -0,0 +1,37 @@
const resourceName = window.GetParentResourceName?.() ?? 'sky_phone'
export type NuiResponse<T = unknown> = {
success: boolean
data?: T
error?: string
}
export async function nuiCall<T = unknown>(
endpoint: string,
data: Record<string, unknown> = {},
): Promise<NuiResponse<T>> {
const baseUrl = import.meta.env.DEV
? 'http://localhost:3001/api'
: `https://${resourceName}`
try {
const response = await fetch(`${baseUrl}/${endpoint}`, {
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
if (!response.ok) {
const error = `${response.status} ${response.statusText}`
console.error(`[NUI] ${endpoint} failed: ${error}`)
return { error, success: false }
}
const body = await response.text()
return body ? (JSON.parse(body) as NuiResponse<T>) : { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error(`[NUI] ${endpoint} failed:`, error)
return { error: message, success: false }
}
}
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { kBlock, kButton, kGlass, kNavbar, kPage } from 'konsta/vue'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
const phone = usePhoneStore()
function closePhone(): void {
phone.close()
void nuiCall('close')
}
</script>
<template>
<k-page class="phone-home">
<k-navbar :title="phone.t('Phone.title')" class="top-0 sticky" />
<div class="phone-home__content">
<k-glass class="mx-4 rounded-3xl p-4">
<h1 class="mb-2 text-xl font-semibold">
{{ phone.t('Phone.readyTitle') }}
</h1>
<p class="text-sm leading-6 text-black/60">
{{ phone.t('Phone.readyBody') }}
</p>
</k-glass>
<k-block inset>
<k-button large rounded @click="closePhone">
{{ phone.t('Phone.close') }}
</k-button>
</k-block>
</div>
</k-page>
</template>
+17
View File
@@ -0,0 +1,17 @@
const cors = require('cors')
const express = require('express')
const app = express()
const port = 3001
app.use(cors())
app.use(express.json())
app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
response.json({ success: true })
})
app.listen(port, () => {
console.log(`Mock NUI server listening on http://localhost:${port}`)
})
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["vite.config.ts"],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
base: './',
build: {
assetsDir: 'assets',
emptyOutDir: true,
outDir: 'dist',
rollupOptions: {
output: {
assetFileNames: 'assets/sky-[name]-[hash].[ext]',
chunkFileNames: 'assets/sky-[name]-[hash].js',
entryFileNames: 'assets/sky-[name]-[hash].js',
},
},
},
plugins: [tailwindcss(), vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
+2
View File
@@ -0,0 +1,2 @@
Config.Command = "phone"
+3
View File
@@ -0,0 +1,3 @@
Config = Config or {}
Locales = Locales or {}
+12
View File
@@ -0,0 +1,12 @@
Locales["en"] = {
CommandDescription = "Open your phone.",
Nui = {
Phone = {
title = "Phone",
readyTitle = "Boilerplate ready",
readyBody = "Konsta UI is connected to the FiveM NUI bridge.",
close = "Close",
},
},
}
+30
View File
@@ -0,0 +1,30 @@
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.1.0'
escrow_ignore 'config/**'
shared_scripts {
'@sky_base/source/import.lua',
'config/init.lua',
}
client_scripts {
'config/config.lua',
'config/locales/*.lua',
'source/client/main.lua',
}
files {
'source/html/index.html',
'source/html/assets/**',
}
ui_page 'source/html/index.html'
dependency 'sky_base'
+73
View File
@@ -0,0 +1,73 @@
local is_open = false
local function get_locale()
return Locales[Sky.Config.locale] or Locales["en"]
end
local function send_open_message()
SendNUIMessage({
type = "app:open",
data = {
lang = Sky.Config.locale,
locales = get_locale().Nui,
},
})
end
local function open_phone()
if is_open then
return
end
is_open = true
SetNuiFocus(true, true)
send_open_message()
end
local function close_phone()
if not is_open then
return
end
is_open = false
SetNuiFocus(false, false)
SendNUIMessage({ type = "app:close" })
end
RegisterCommand(Config.Command, function()
if is_open then
close_phone()
return
end
open_phone()
end, false)
RegisterNUICallback("ui:ready", function(_, cb)
if is_open then
send_open_message()
end
cb({ success = true })
end)
RegisterNUICallback("close", function(_, cb)
close_phone()
cb({ success = true })
end)
CreateThread(function()
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription)
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
if is_open then
SetNuiFocus(false, false)
end
TriggerEvent("chat:removeSuggestion", "/" .. Config.Command)
end)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-D5IGMOYX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DMw9emdW.css">
</head>
<body>
<div id="app"></div>
</body>
</html>