main
reng 2 weeks ago
parent e2ced4e464
commit b7da60714d
  1. 12
      public/data.json
  2. 4
      src/App.jsx
  3. 2
      src/assets/lottie/Grayscale_landing.json
  4. 12
      src/comps/button.jsx
  5. 49
      src/comps/canvas.jsx
  6. 11
      src/index.css
  7. 6
      src/main.jsx
  8. 55
      src/pages/about.jsx
  9. 56
      src/pages/explore.jsx
  10. 4
      src/utils/firebase.js
  11. 8
      src/utils/usetext.jsx

@ -15,6 +15,10 @@
"en": "reselect", "en": "reselect",
"zh": "重新選擇" "zh": "重新選擇"
}, },
"button_next": {
"en": "next",
"zh": "繼續"
},
"title_keywords": { "title_keywords": {
"en": "Select 4 signals to observe.", "en": "Select 4 signals to observe.",
"zh": "選擇四個想觀測的訊號" "zh": "選擇四個想觀測的訊號"
@ -32,16 +36,16 @@
"zh": "關於作品" "zh": "關於作品"
}, },
"content_about": { "content_about": {
"en": "What defines an \"adult\"? In reality, the standards are never consistent. We are expected to be mature and responsible, yet we are often treated as individuals who are not quite \"ready\". This unclassifiable state is our shared experience.\nThe visuals here are culled from fragmented social media discussions on career, age, and social norms. Though scattered, they all point to the same \"gray dilemma\" occurring everywhere.\nThrough AI, these isolated voices are aggregated into this single device. It offers no answers, but forms a new shape from these dialogues—inviting you to step closer and observe.", "en": "This is an experiment inspired by a course.\n\nWhat does it mean to \"grow up\"? The standard has never been consistent. As we age, we're expected to be mature and responsible—yet often seen as not quite ready.\n\nThe images you see come from social media discussions about age, work, career planning, and social expectations—brief and scattered, yet all pointing to the same gray dilemma.\nAI gathers these conversations here, forming a new shape.",
"zh": "作品源於 2025 年陽明交大應藝所、建築所與害喜影音綜藝共同發起的一場實踐。相較於校園中常見的傳統公共藝術,我們試圖從學習者的視角出發,將那些發生在校園裡的迷惘與不確定,變成一場關於公共性的思辨。\n我們在思考:究竟什麼是「長大成人」?在生活中,標準從未真正一致。隨著年紀增長,人被期待表現成熟、負責,但又常常在社會中,被視為還沒準備好的個體。這種難以歸類的狀態,構成共同經驗。\n你所看到的影像,來自許多人在社群平台上,對於年紀、職場、生涯規劃與社會規範留下的討論。這些發言短暫、零散,卻指向同一種灰色的困境——不論幾歲、不論在哪,都可能正發生。\n透過 AI 與數位,這些原本彼此獨立的討論被放在同一個裝置裡。作品並非試圖給出答案,而是將這些對話聚集在一起,形成一個新的形狀,邀請你靠近觀察。" "zh": "這是一場受課程啟發的實驗。\n\n什麼是「長大成人」?標準從未真正一致。隨著年紀增長,人被期待表現成熟、負責,但又常常在社會中,被視為還沒準備好的個體。\n\n你看到的影像,來自社群平台上關於年紀、職場、生涯的討論——短暫、零散,卻指向同一種灰色的困境。\n\nAI 將這些對話聚集於此,形成一個新的形狀。"
}, },
"title_credits": { "title_credits": {
"en": "Credit", "en": "Credit",
"zh": "製作團隊" "zh": "製作團隊"
}, },
"content_credits": { "content_credits": {
"en": "Artwork by ULTRACOMBOS", "en": "ULTRACOMBOS",
"zh": "Artwork by ULTRACOMBOS" "zh": "叁式"
}, },
"title_share": { "title_share": {
"en": "SHARE YOUR THOUGHTS", "en": "SHARE YOUR THOUGHTS",

@ -13,13 +13,13 @@ function App() {
return ( return (
<main> <main>
<LangButton lang={nextLang} onClick={toggleLang} /> {/* <LangButton lang={nextLang} onClick={toggleLang} /> */}
{/* <img src="/main.png" alt="Main" className='w-full flex-1 object-contain'/> */} {/* <img src="/main.png" alt="Main" className='w-full flex-1 object-contain'/> */}
<div className='w-full flex-1 flex items-center justify-center'> <div className='w-full flex-1 flex items-center justify-center'>
<Lottie animationData={landing_animation} loop={true}/> <Lottie animationData={landing_animation} loop={true}/>
</div> </div>
<Button onClick={()=>{ <Button onClick={()=>{
navigate('/keywords'); navigate('/about');
}}>{getText('button_enter')}</Button> }}>{getText('button_enter')}</Button>
</main> </main>
) )

File diff suppressed because one or more lines are too long

@ -1,8 +1,10 @@
import { useText } from '../utils/usetext.jsx';
export function Button({ children, onClick, ...props }) { export function Button({ children, onClick, ...props }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`w-full bg-black text-white border border-white uppercase text-[1.25rem] ${props.className} disabled:border-[#939393] disabled:text-[#939393] disabled:bg-transparent`} className={`w-full bg-black text-white border border-white uppercase text-[1.25rem] ${props.className} disabled:border-[#939393] disabled:text-[#939393] disabled:bg-transparent py-[0.25rem] leading-[2rem]`}
{...props} {...props}
> >
{children} {children}
@ -12,12 +14,14 @@ export function Button({ children, onClick, ...props }) {
export function LangButton({ lang, onClick }) { export function LangButton({ lang, onClick }) {
const {nextLang, toggleLang}=useText();
return ( return (
<button <button
onClick={onClick} onClick={toggleLang}
className="bg-black text-white border border-white uppercase rounded-full text-[0.875rem] w-[1.875rem] aspect-square flex items-center justify-center absolute top-[1.5rem] right-[1.5rem]" className="fixed top-[1.5rem] right-[1.5rem] z-20 bg-black text-white border border-white uppercase rounded-full text-[0.875rem] w-[1.875rem] aspect-square flex items-center justify-center"
> >
{lang} {nextLang}
</button> </button>
); );
} }

@ -6,7 +6,7 @@ const StartUpdateInterval=1800;
const PointCount=4; const PointCount=4;
const PointScale=0.7; const PointScale=0.7;
export default function Canvas({ lookat, ...props }) { export default function Canvas({ lookat, updateTarget, ...props }) {
console.log('Canvas lookat', lookat); console.log('Canvas lookat', lookat);
@ -21,6 +21,8 @@ export default function Canvas({ lookat, ...props }) {
const refPoints=useRef([]); const refPoints=useRef([]);
const refLookat=useRef(lookat); const refLookat=useRef(lookat);
const refTargetProgress=useRef(0);
@ -130,34 +132,45 @@ export default function Canvas({ lookat, ...props }) {
refPoints.current.forEach((point, index) => { refPoints.current.forEach((point, index) => {
if(index===refLookat.current){ if(index===refLookat.current){
// updateTarget(point.x, point.y);
context.beginPath(); context.beginPath();
context.arc(point.x, point.y, 5, 0, Math.PI * 2); context.arc(point.x, point.y, 5, 0, Math.PI * 2);
context.fillStyle = 'rgba(255, 255, 255, 1)'; context.fillStyle = 'rgba(255, 255, 255, 1)';
context.fill(); context.fill();
const progress=Math.abs(Math.sin(refTargetProgress.current));
const rad = progress*8+5;
context.beginPath(); context.beginPath();
context.arc(point.x, point.y, 10, 0, Math.PI * 2); context.arc(point.x, point.y, rad, 0, Math.PI * 2);
context.strokeStyle = 'rgba(255, 255, 255, 1)'; context.strokeStyle =`rgba(255, 255, 255, ${1-progress})`;
context.stroke(); context.stroke();
}
// radiations around point refTargetProgress.current+=0.01;
for(let i=0; i<point.radiations; i++){ refTargetProgress.current%=Math.PI*0.5;
const angle = (i / point.radiations) * point.radiation_fan + point.start_angle;
const x = point.x + Math.cos(angle) * point.radius *(canvas.width/100);
const y = point.y + Math.sin(angle) * point.radius*(canvas.width/100);
context.beginPath();
// context.arc(x, y, 1, 0, Math.PI * 2);
// context.fillStyle = 'rgba(255, 255, 255, 0.8)';
// context.fill();
context.moveTo(point.x, point.y); }else{
context.lineTo(x, y); context.beginPath();
context.strokeStyle = 'rgba(255, 255, 255, 1)'; context.arc(point.x, point.y, 3, 0, Math.PI * 2);
context.stroke(); context.fillStyle = 'rgba(255, 255, 255, 1)';
context.fill();
} }
// radiations around point
// for(let i=0; i<point.radiations; i++){
// const angle = (i / point.radiations) * point.radiation_fan + point.start_angle;
// const x = point.x + Math.cos(angle) * point.radius *(canvas.width/100);
// const y = point.y + Math.sin(angle) * point.radius*(canvas.width/100);
// context.beginPath();
// // context.arc(x, y, 1, 0, Math.PI * 2);
// // context.fillStyle = 'rgba(255, 255, 255, 0.8)';
// // context.fill();
// context.moveTo(point.x, point.y);
// context.lineTo(x, y);
// context.strokeStyle = 'rgba(255, 255, 255, 1)';
// context.stroke();
// }
}); });
// draw lines along with points // draw lines along with points
for(let i=0; i<refPoints.current.length-1; i++){ for(let i=0; i<refPoints.current.length-1; i++){

@ -7,8 +7,8 @@ body{
@apply flex justify-center items-center min-h-screen; @apply flex justify-center items-center min-h-screen;
} }
#root{ #root{
@apply flex-1 overflow-hidden bg-black text-white flex justify-center items-center w-full max-w-[640px] ; @apply flex-1 overflow-x-hidden bg-black text-white flex justify-center items-center w-full max-w-[640px] ;
@apply overflow-y-auto;
} }
main{ main{
@apply bg-black text-white flex justify-center items-center min-h-screen px-[1rem] py-[3.5rem] flex flex-col justify-end gap-[3rem]; @apply bg-black text-white flex justify-center items-center min-h-screen px-[1rem] py-[3.5rem] flex flex-col justify-end gap-[3rem];
@ -20,8 +20,9 @@ button{
} }
h1{ h1{
@apply text-[1.125rem] leading-[1.375rem] uppercase; @apply text-[1.125rem] leading-[1.25rem] py-[1rem] uppercase tracking-[0.0225rem];
} }
p{ p{
@apply text-[0.75rem] leading-[1.25rem]; @apply text-[0.875rem] leading-[1.75rem];
} }

@ -9,6 +9,7 @@ import Explore from './pages/explore.jsx'
import { createBrowserRouter, createHashRouter } from "react-router"; import { createBrowserRouter, createHashRouter } from "react-router";
import { RouterProvider } from "react-router/dom"; import { RouterProvider } from "react-router/dom";
import { TextProvider } from './utils/usetext.jsx' import { TextProvider } from './utils/usetext.jsx'
import { LangButton } from './comps/button.jsx'
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -16,6 +17,10 @@ const router = createBrowserRouter([
path: "/", path: "/",
element: <App />, element: <App />,
}, },
{
path: '/about',
element: <About />,
},
{ {
path: '/keywords', path: '/keywords',
element: <Keywords />, element: <Keywords />,
@ -29,6 +34,7 @@ const router = createBrowserRouter([
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<TextProvider> <TextProvider>
<LangButton/>
<RouterProvider router={router} /> <RouterProvider router={router} />
</TextProvider> </TextProvider>
</StrictMode>, </StrictMode>,

@ -1,24 +1,47 @@
import { Button } from '../comps/button.jsx';
import { useText } from '../utils/usetext.jsx'; import { useText } from '../utils/usetext.jsx';
import { useNavigate, useLocation } from 'react-router-dom';
export default function About({showContact, onClose}) {
const navigate=useNavigate();
export default function About() {
const { getText } = useText(); const { getText } = useText();
return ( return (
<main className="z-10 fixed top-0 left-0 right-0 bg-black text-white min-h-screen flex flex-col !justify-start !items-stretch !gap-[2.63rem]"> <main className="z-10 absolute top-0 left-0 right-0 min-h-[100dvh] !justify-start bg-black text-white overflow-y-auto py-[3.12rem]">
<div className='flex flex-col gap-[1.38rem]'> <div className='flex-1 flex flex-col justify-between items-stretch !gap-[1.44rem]'>
<h1>{getText('title_about')}</h1> <div className='flex flex-col gap-[1.38rem] flex-1 flex flex-col justify-start'>
<p>{getText('content_about')}</p> <div className='text-[1.125rem] leading-[1.25rem] py-[1rem] uppercase tracking-[0.0225rem] text-center '>
</div> <b>{getText('title')}</b> / {getText('content_credits')}
<div className='flex flex-col gap-[1.38rem]'> </div>
<h1>{getText('title_credits')}</h1> <p className='px-[1.53rem] whitespace-pre-line'>{getText('content_about')}</p>
<p>{getText('content_credits')}</p>
</div> {showContact &&(
<div className='flex flex-col gap-[1.38rem]'> <div className='px-[1.53rem] flex flex-col gap-[0.62rem] my-[3rem] items-center'>
<h1>{getText('title_share')}</h1> <h1>{getText('title_share')}</h1>
<span className='flex flex-row gap-[0.5rem] items-center'> <span className='flex flex-row gap-[0.5rem] items-center'>
<span className='p-[0.06rem]'><img className='w-[1rem]' src="letter.svg "/></span> <span className='p-[0.06rem]'><img className='w-[1rem]' src="letter.svg "/></span>
<a className='underline' href="mailto:contact@ultracombos.com">contact@ultracombos.com</a> <a className='underline' href="mailto:contact@ultracombos.com">contact@ultracombos.com</a>
</span> </span>
</div>
)}
</div>
{/* <div className='flex flex-col gap-[1.38rem]'>
<h1>{getText('title_credits')}</h1>
<p>{getText('content_credits')}</p>
</div> */}
<Button onClick={()=>{
if(typeof onClose === 'function') onClose();
else navigate('/keywords');
}}>
{getText('button_next')}
</Button>
</div> </div>
</main> </main>
); );

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "../comps/button"; import { Button } from "../comps/button";
import { useText } from "../utils/usetext.jsx"; import { useText } from "../utils/usetext.jsx";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -7,6 +7,7 @@ import Canvas from "../comps/canvas.jsx";
import Lottie from "lottie-react"; import Lottie from "lottie-react";
import hint_animation from '../assets/lottie/hint.json'; import hint_animation from '../assets/lottie/hint.json';
import egg_bg from '../assets/lottie/egg_bg.json'; import egg_bg from '../assets/lottie/egg_bg.json';
import circle from '../assets/lottie/circle.json';
import { lookatKeyword } from "../utils/firebase.js"; import { lookatKeyword } from "../utils/firebase.js";
const State={ const State={
@ -14,6 +15,8 @@ const State={
Explore: 'Explore', Explore: 'Explore',
} }
const AnimationTime=1000;
export default function Explore() { export default function Explore() {
const { selectedKeyword, getText } = useText(); const { selectedKeyword, getText } = useText();
@ -26,6 +29,19 @@ export default function Explore() {
const [state, setState] = useState(State.Intro); const [state, setState] = useState(State.Intro);
const [lookat, setLookat] = useState(selectedKeyword[0] || null); const [lookat, setLookat] = useState(selectedKeyword[0] || null);
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
const sortedKeywords = useMemo(() => [...selectedKeyword]?.sort((a, b) => a.id - b.id), [selectedKeyword]);
console.log(sortedKeywords);
const refTarget=useRef();
function updateTarget(x,y){
if(refTarget.current){
refTarget.current.style.left=`${x}px`;
refTarget.current.style.top=`${y}px`;
}
}
useEffect(()=>{ useEffect(()=>{
@ -37,7 +53,7 @@ export default function Explore() {
useEffect(()=>{ useEffect(()=>{
const timeoutId = setTimeout(()=>{ const timeoutId = setTimeout(()=>{
setState(State.Explore); setState(State.Explore);
}, 2000); }, AnimationTime);
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@ -58,56 +74,60 @@ export default function Explore() {
return ( return (
<> <>
<main className="!px-0 !gap-0 !justify-start"> <main className="!px-0 !gap-0 !justify-start">
<section className="z-10 absolute top-[1.5rem] left-[1.5rem] right-[1.5rem] flex flex-row justify-between items-center"> <section className="z-10 fixed top-[1.5rem] left-[1.5rem] right-[1.5rem] flex flex-row justify-between items-center">
<button className="uppercase flex flex-row" onClick={()=>{ <button className="uppercase flex flex-row" onClick={()=>{
navigate('/keywords'); navigate('/keywords');
}}><img src="back.svg" alt="back"/>{getText('button_reselect')}</button> }}><img src="back.svg" alt="back"/>{getText('button_reselect')}</button>
<button className="z-20 w-[1.875rem] border aspect-square flex justify-center items-center rounded-full" <button className="z-20 mr-[2.5rem] w-[1.875rem] border aspect-square flex justify-center items-center rounded-full"
onClick={()=>{ onClick={()=>{
setShowInfo(!showInfo); setShowInfo(!showInfo);
}}>{!showInfo?'i':<img src="x.svg" alt="close"/>}</button> }}>{!showInfo?'i':<img src="x.svg" alt="close"/>}</button>
</section> </section>
<div className="w-full flex-1 flex justify-center items-center overflow-hidden"> <div className="w-full flex justify-center items-center overflow-hidden p-[1rem]">
<div className="w-full relative aspect-square"> <div className="w-full relative aspect-square">
{/* <img src="egg_bg.svg" alt="egg" className="w-full object-contain"/> */} {/* <img src="egg_bg.svg" alt="egg" className="w-full object-contain"/> */}
<Canvas className="absolute inset-0 top-0 left-0 w-full h-full transform-origin-center transform-[scale(1.1)]" <Canvas className="absolute inset-0 top-0 left-0 w-full h-full transform-origin-center transform-[scale(1.1)]"
lookat={selectedKeyword.indexOf(lookat)}/> lookat={selectedKeyword.indexOf(lookat)}
updateTarget={updateTarget}/>
<div className="absolute inset-0 top-0 left-0 w-full h-full transform-origin-center transform-[scale(1.1)]"> <div className="absolute inset-0 top-0 left-0 w-full h-full transform-origin-center transform-[scale(1.1)]">
<img src="egg_bg(500x500).png" alt="egg" className="absolute w-full object-contain"/> <img src="egg_bg(500x500).png" alt="egg" className="absolute w-full object-contain"/>
<Lottie animationData={egg_bg} loop={true} className='absolute w-full h-full'/> <Lottie animationData={egg_bg} loop={true} className='absolute w-full h-full'/>
</div> </div>
{/* <div ref={refTarget} className="absolute w-[34px] h-[34px]">
<Lottie animationData={circle} loop={true} className='absolute translate-x-[-50%] translate-y-[-50%]'/>
</div> */}
</div> </div>
</div> </div>
<div className="flex flex-row gap-[0.75rem] justify-center items-center mt-[1.5rem]"> <div className="flex flex-row gap-[0.75rem] justify-center items-center mt-[0.41rem]">
<img src="eye.svg" alt="eye" className="w-[0.9965rem] object-contain"/> <img src="eye.svg" alt="eye" className="w-[0.9965rem] object-contain"/>
<div className="text-[0.75rem]">{getText('title_observe')}</div> <div className="text-[1rem]">{getText('title_observe')}</div>
</div> </div>
<section className="w-full px-[1.75rem] flex flex-col mt-[1.44rem]"> <section className="w-full px-[2.44rem] flex flex-col mt-[1.22rem]">
<div className="w-full overflow-x-auto py-[1rem]"> <div className="w-full grid grid-cols-2 gap-[0.75rem]">
<div className="flex flex-row gap-[0.75rem]"> {/* <div className="flex flex-row flex-wrap gap-[0.75rem]"> */}
{selectedKeyword.map((keyword, index) => ( {sortedKeywords.map((keyword, index) => (
<div key={index} className="w-full flex justify-center items-center border border-white" <div key={index} className="flex justify-center items-center border border-white"
style={{ style={{
backgroundColor: lookat && lookat.title===keyword.title ? '#939393' : 'transparent', backgroundColor: lookat && lookat.title===keyword.title ? '#939393' : 'transparent',
// color: lookat && lookat.title===keyword.title ? 'black' : 'white', // color: lookat && lookat.title===keyword.title ? 'black' : 'white',
}} }}
onClick={()=>setLookat(keyword)}> onClick={()=>setLookat(keyword)}>
<span className="text-center text-nowrap text-[0.6875rem] px-[0.97rem] py-[0.47rem]">{keyword.title}</span> <span className="text-center text-nowrap text-[1rem] py-[1.25rem]">{keyword.title}</span>
</div> </div>
))} ))}
</div> {/* </div> */}
</div> </div>
<div className="w-full py-[0.5rem]"> <div className="w-full mt-[1.31rem]">
{lookat && ( {lookat && (
<div className="text-[0.875rem] tracking-[0.0875rem] leading-[160%]">{lookat.content}</div> <div className="text-[1rem] tracking-[0.1rem] leading-[160%]">{lookat.content}</div>
)} )}
</div> </div>
</section> </section>
</main> </main>
{showInfo && <About/>} {showInfo && <About onClose={() => setShowInfo(false)} showContact={true}/>}
</> </>
); );
} }

@ -24,10 +24,12 @@ export function addKeywords(keywords) {
console.log('Adding keywords to Firestore:', keywords); console.log('Adding keywords to Firestore:', keywords);
const sortedKeywords = [...keywords].sort((a, b) => a.id - b.id);
const db = getFirestore(app); const db = getFirestore(app);
const batch = writeBatch(db); const batch = writeBatch(db);
keywords.forEach((keyword) => { sortedKeywords.forEach((keyword) => {
const keywordRef = doc(db, "keywords", keyword.id.toString()); const keywordRef = doc(db, "keywords", keyword.id.toString());

@ -16,13 +16,13 @@ export function TextProvider({children}) {
return res.json(); return res.json();
}); });
const [lang, setLang]=useState("en"); const [lang, setLang]=useState('zh');
const [nextLang, setNextLang]=useState(LANG.zh); const [nextLang, setNextLang]=useState('en');
const [selectedKeyword, setSelectedKeyword] = useState([]); const [selectedKeyword, setSelectedKeyword] = useState([]);
function getText(key) { function getText(key) {
// console.log("getText", key, lang, data?.[key]?.[lang]); console.log("getText", key, lang, data?.[key]?.[lang]);
return data?.[key]?.[lang] || ""; return data?.[key]?.[lang] || "";
} }
@ -43,6 +43,8 @@ export function TextProvider({children}) {
}else{ }else{
if(selectedKeyword.length>=4){ if(selectedKeyword.length>=4){
// remove the first one, insert the new one at the end // remove the first one, insert the new one at the end
// const merged = [...selectedKeyword.slice(1), select];
setSelectedKeyword([...selectedKeyword.slice(1), select]); setSelectedKeyword([...selectedKeyword.slice(1), select]);
}else{ }else{
setSelectedKeyword([...selectedKeyword, select]); setSelectedKeyword([...selectedKeyword, select]);

Loading…
Cancel
Save